SceneGraph Cross‑Thread Rendezvous

July 24, 2026 · View on GitHub

This document explains how brs‑engine implements the SceneGraph cross‑thread rendezvous — the mechanism that lets a Task thread read and write nodes owned by the render thread — and how the implementation evolved from a main‑thread broker relay to the current direct render→task channel. It closes with an analysis of the change across performance, reliability, memory, and fidelity to real Roku behavior.

Audience: contributors working on src/extensions/scenegraph/ and src/api/task.ts. For the consumer‑facing view of the SceneGraph extension see extensions.md.

Background: threads, ownership, and rendezvous

SceneGraph apps run BrightScript on multiple threads, mirroring a real Roku device:

  • Render thread — owns the Scene, m.global, and every Task node; renders the display. In the engine this is the worker running brs.worker.js with thread id 0.
  • Task threads — each running Task node executes its functionName on a dedicated worker (thread id > 0) for blocking work (network, file I/O, parsing).
  • Main thread — the page/host. It runs no interpreter; it owns the canvas, audio/video, and input, and historically also acted as the message broker between workers.

Every node has a single owning thread. Reading or writing a node you don't own must rendezvous: a synchronous, blocking request to the owner that returns a value as if the call had executed locally. On a real device the render thread serves these requests directly; Roku's spec notes that each field access on a render‑owned node is "a distinct rendezvous."

The engine implements rendezvous with Web Workers and a SharedArrayBuffer mailbox (src/core/SharedObject.ts): a single‑slot buffer with an [length, version] header and a JSON payload, coordinated with Atomics.wait/waitAsync/notify.

The original solution: a main‑thread broker

In the original design, neither worker held a reference to the other's buffer. Each task had two SharedObjects, both owned by the main‑thread broker (src/api/task.ts):

  • threadSyncToMain — the render thread's inbound buffer (task → render).
  • threadSyncToTask — the task thread's inbound buffer (render → task).

Every cross‑thread message was relayed by the broker:

TASK→RENDER request:  task ──postMessage──▶ main thread (broker) ──SAB──▶ render
RENDER→TASK response: render ──postMessage──▶ main thread (broker) ──SAB──▶ task

A single rendezvous round‑trip therefore required the main‑thread event loop to be free twice, and each payload was encoded up to three times: structured‑clone over postMessage, then JSON.stringify into the SAB, then JSON.parse on receipt. The SharedArrayBuffer was used only to wake the receiver, not as the actual transport.

This worked and was broadly correct, but it diverged from the device (where the render thread serves requests directly) and made the main thread a latency, jitter, and contention bottleneck on the rendezvous data path.

Hardening that preceded the structural change

Before removing the broker, several fidelity/reliability fixes landed and are retained in the final design:

  • requestId‑correlated responses — every get/call/set response carries the originating request's id, so responses can never be mismatched under interleaving.
  • Device‑accurate timeouts — a rendezvous that is not served within the timeout raises an ExecutionTimeout runtime error (mirroring a device terminating an app on a blocked render thread) instead of silently returning invalid/false.
  • roRenderThreadQueue — the OS 15 non‑blocking task→render messaging API.
  • Faithful node‑field serialization — fields holding invalid retain their declared type when crossing threads.
  • freshFields gated behind fastFieldReads — by default every render‑owned field read rendezvouses, matching the device rule, instead of returning a locally cached value.

The final solution: a direct render→task channel

The key realization is that SharedArrayBuffer + Atomics connect two worker threads directly — the main thread does not need to be involved in the data path. The final design gives the render thread dedicated buffers it writes straight into the task worker, removing the broker hop in the render→task direction.

Each running task now has three SharedObjects, all created on the render‑thread Task instance when the task activates (Task.ensureDirectBuffers) and handed to the task worker via TaskData:

BufferDirectionCarriesWriter → Reader
taskBuffer (threadSyncToMain)task → renderrendezvous requests (get/set/call), roRenderThreadQueue poststask → (broker) → render
directBufferrender → taskrendezvous responses (set/resp/ack/nil)render → task (direct)
fanoutBufferrender → taskobserved‑field fan‑out + cross‑task propagationrender → task (direct)

Resulting data paths:

TASK→RENDER request:   task ──postMessage──▶ main thread (broker) ──SAB──▶ render   (unchanged)
RENDER→TASK response:  render ──SAB(directBuffer)──▶ task                            (direct)
RENDER→TASK fan-out:   render ──SAB(fanoutBuffer)──▶ task                            (direct)

Two render‑thread constraints shaped the design:

  1. Responses are written synchronously. When the render serves a request, it writes the response straight into directBuffer with store() and the blocked task wakes via Atomics.
  2. Fan‑out is queued, then flushed. The render worker busy‑waits to enforce its frame rate (RoSGScreen) and does not yield its event loop mid‑frame, so it cannot rely on asynchronous writes. Render‑side field fan‑out is enqueued (fanoutQueue) and drained synchronously into the single‑slot fanoutBuffer during processTasks — one update per free slot, the rest stay queued for the next pass. This is non‑blocking and lossless without a lock‑free ring buffer.

Cross‑task propagation (one task setting a global/scene/node field that another task observes) is performed by the render thread directly: when it applies a task's set, it fans the new value out to the other observing tasks, excluding the originator. Per‑task (task‑type) fields are never cross‑fanned — they are private to one task — matching the broker's original type !== "task" rule.

The task→render request direction still goes through the broker: the task postMessages the request and the broker writes it into threadSyncToMain to wake the render thread. Making that direction direct as well (plus a ring‑buffer protocol and single‑copy/msgpack encoding) is the remaining future step.

Reliability hardening in the final design

Moving work onto the render thread made it essential that task‑servicing can never destabilize the render loop:

  • Per‑task fault isolationprocessTasks snapshots the task list and wraps each task's servicing in try/catch, so a fault handling one task cannot tear down the render thread (which would stall every rendezvous and silently drop in‑flight updates).
  • Fan‑out flush resilience — a non‑serializable payload is dropped and logged rather than thrown into the render loop.
  • Ownership preservation — render‑side fan‑out no longer re‑owns the node it sends (setOwner(0) is only applied when a node genuinely crosses task → render), so fanning a value out can't corrupt the live render‑owned node tree.
  • Teardown safetySharedObject.dispose() cancels pending Atomics waits when a task is torn down, so a late timeout from a terminated app can't surface a stale "dropped update" error against the next app to run.
  • Diagnostics — dropped‑update messages identify the exact update ({id action type.key}) and buffer (toMain/toTask), and a broker warning fires if a render→task fan‑out ever leaks back onto the broker path.

Analysis: final vs. broker

The comparison below is the direct render→task design against the original broker‑relay design (both share the requestId, timeout, roRenderThreadQueue, and serialization work listed above).

Performance

Broker relayDirect render→task
Render→task hopsrender → main thread → task (2 event‑loop hops)render → task (0 intermediate hops)
Encodings per messageup to 3: structured‑clone + JSON.stringify + JSON.parse2: JSON.stringify + JSON.parse
Main‑thread loadevery response/fan‑out occupies the page event looppage uninvolved; freed for UI/input/audio/video
  • Lower and more consistent latency and jitter on responses and fan‑out, since delivery no longer waits for the main thread to be free twice.
  • One fewer serialization per render→task message (no structured clone) and no main‑thread contention — most noticeable for chatty tasks and large content‑node transfers.
  • Per‑message encoding is still JSON; single‑copy / msgpack encoding remains future work, so the raw encoding cost per message is unchanged.

Reliability

  • The broker's single‑slot relay is no longer in the render→task path, so it cannot drop those updates under back‑pressure.
  • Failures while servicing a task are contained and observable (fault isolation, flush resilience, precise diagnostics) instead of fatal to the render thread.
  • The ownership‑preservation and task‑type cross‑fan‑exclusion fixes remove a class of node‑tree‑corruption bugs that the render‑side propagation could otherwise introduce.
  • Net: fewer silent‑loss paths and no new fatal paths versus the broker design.

Memory usage

  • Slightly higher baseline: ~3 growable SharedArrayBuffers per active task (taskBuffer + directBuffer + fanoutBuffer) vs. ~2 in the broker design (threadSyncToMain + threadSyncToTask) — one extra buffer per task (each starts at 32 KB with the same growth cap).
  • Lower transient/peak: the main thread no longer holds a relayed copy of every payload, so large node trees are not double‑buffered through the page.
  • A small render‑side JS fan‑out queue is added, bounded by how quickly a task drains its buffer.
  • Overall a modest fixed per‑task cost traded for less peak duplication during big transfers.

Adherence to Roku specs

  • On a real device the render thread serves rendezvous directly; there is no page/broker relaying between render and task threads. The direct render→task channel is architecturally closer to the device model.
  • Reduced, more uniform latency also makes cross‑thread timing behave more like hardware.
  • The design retains device‑accurate timeout semantics (ExecutionTimeout rather than a silent invalid).
  • Partial: only the render→task direction is direct today; task→render requests still traverse the broker, so this is a step toward the device model rather than the complete picture.

Remaining gaps / future work

  • Make the task→render request direction direct as well (currently broker‑relayed).
  • Replace the single‑slot mailbox with a ring‑buffer / requestId‑correlated protocol to allow multiple in‑flight messages without head‑of‑line blocking.
  • Carry payloads once in the SAB and switch node‑tree encoding to msgpack (already a dependency) to remove the remaining JSON encode/decode cost.
  • Task threading/rendezvous remains browser‑only — the Node host (src/node/host.ts + src/node/task.ts, a port of src/api/task.ts) now spawns app and task workers via worker_threads, with the same broker/direct-buffer data paths.

Key source references

  • src/extensions/scenegraph/nodes/Task.ts — rendezvous transport, request/response/ack, direct buffers (ensureDirectBuffers, directBuffer, fanoutBuffer, flushFanout).
  • src/extensions/scenegraph/nodes/Node.tsshouldRendezvous, rendezvousSet/rendezvousCall, ownership (setOwner), fanOutFieldToObservingTasks.
  • src/extensions/scenegraph/SGRoot.tsprocessTasks (drain + flush + fault isolation), thread map, render‑loop integration.
  • src/api/task.ts — main‑thread broker: task spawn, threadSyncToMain relay for task→render requests, teardown/disposal.
  • src/core/SharedObject.ts — the growable SharedArrayBuffer mailbox.