' ============================================================================
' tasks.bs: the one accounted chokepoint for starting a Task thread
' ============================================================================
'
' RokuOS caps an app instance at 100 concurrent threads (main + render + every
' Task thread); the port-8085 console warns past 50 and a `&h29` "too many task
' threads" exception is raised past 100. Epic #728 is that crash, produced by
' launch sites that scaled with server data while nothing in the app could see
' the total.
'
' `launchTask()` is the single place a Task thread starts. The `no-raw-run` BSC
' plugin makes a bare `control = "RUN"` anywhere else a build error, so the
' chokepoint holds by construction rather than by convention.
'
' ── The count is DERIVED, never tracked ────────────────────────────────────
'
' A counter would need a decrement when the thread exits, that is, an
' `observeField("state")` per launch, which is the unpaired-observer leak shape
' PR #765 spent a whole pass removing, and which neither teardown plugin can see
' on a component that isn't a `JRScreen`.
'
' Instead the ledger holds node references and the count is derived on demand by
' reading each node's `state`. That is exact rather than approximate, because
' Roku's own rule is stated in terms of `state`: a Task thread that has
' terminated does not count toward the cap "even if the task object itself is
' still valid". So a stopped or done node is not a thread, and a `control =
' "STOP"` needs no accounting call at all: the next read sees it.
'
' Measured on device rather than reasoned about: a Task whose function returns
' NATURALLY reports `state = "stop"`, and never reverts to `"init"`. That is the
' one way the derived count could have been silently wrong (`taskThreadIsLive`
' treats `"init"` as live), so `tests/source/unit/utils/tasks.spec.bs` pins it.
'
' ── Where the ledger lives, and the cheap option that does not work ────────
'
' It is an array field on `m.global`. That costs 555.7 us per launch at a ledger
' depth of 10 (measured 2026-08-23 on a Stick 4K, render thread), and it is the
' cheapest CORRECT option available, though not the cheapest option.
'
' `GetGlobalAA()` is ~500x cheaper: appending there is below the measurement floor,
' against 555.7 us for the m.global round-trip. It cannot be used. GetGlobalAA is
' scoped per COMPONENT, not per thread. That was measured, after an earlier probe got
' this wrong by varying thread and component together: launching and counting inside ONE
' component reads 1, while two components on the SAME render thread read each other
' as 0 (tests/rta/specs/gaa-thread-scope.spec.js). A per-component ledger counts only
' its own component's launches, which is not a thread budget.
'
' So the cost buys the one property nothing else offers: every component shares it.
' A node field is the only cross-component storage SceneGraph has, and reading one
' returns a COPY: `m.global.taskLedger.push(x)` measured a plausible 58 us and left
' the field at its original length after 200 pushes (2026-08-23). So the
' read-modify-WRITE is unavoidable. Full cost model: docs/architecture/threading.md.
'
' The count is exact rather than a lower bound: every thread's launches land in the
' same shared field. The cross-thread race is still possible in principle: there is
' no atomic read-modify-write on a node field, so two launches racing across threads
' read the same copy and one write wins.
'
' That consequence CHANGED when the count started gating behaviour. It used to mean a
' readout low by one. It now also means OVER-ADMISSION: each racer sees a count that
' does not include the other, so both are admitted and the live total can exceed the
' watermark by up to the number of racers. Still accepted, and now for a stated reason
' rather than by inheritance: the overshoot is bounded by concurrent launches (a
' handful, and only bootstrap launches are off the render thread at all) against the
' 50 threads the bound leaves spare below Roku's cap.
'
' ── Pure core, shell and debug readout ─────────────────────────────────────
'
' The ledger arithmetic (`pruneTaskLedger`, `countLiveTaskThreads`,
' `taskThreadIsLive`) is pure: it takes the ledger array explicitly and touches
' no `m`, no globals, no console. Only the shell (`admitTaskLaunch`,
' `recordTaskLaunch`, `sharedTaskLedger`) reads and writes `m.global.taskLedger`.
'
' The shell runs in every build, because the ceiling needs the count (ADR 0031).
' A production build pays the `m.global` round-trip above on every launch. Only
' the console output is `#if debug`: `printTaskThreads()` and the `[TASKS]` prints.
'
' The split keeps the ledger testable: the pure functions take the ledger, and
' `recordTaskLaunch` takes its store, so a Rooibos test drives them with state of
' its own. `apiPipeline.bs` and `apiPromise.bs` split the same way, for the same
' reason (docs/architecture/async.md). Rooibos cannot reach the `#if debug` output
' either: test builds compile against the same `manifest`, with `debug=false`.
'
' ── The ceiling, and why these two numbers ─────────────────────────────────
'
' `launchTask()` starts no thread above TASK_THREAD_WATERMARK live threads (the
' launch waits instead; see below). Roku's cap is 100. Measured peak in real use is
' 11 (2026-08-23, 13-library server, Stick 4K, sampled across a scripted journey by
' tests/rta/specs/task-thread-peak.spec.js), so a
' watermark of 50 is ~4.5x the observed peak: it cannot fire in normal operation,
' only when something is fanning out.
'
' Three numbers, kept apart on purpose:
'
' RTA gate 30 < watermark 50 < Roku's cap 100
'
' The gate fires in TESTING and names the culprit; the ceiling only ever fires in
' PRODUCTION, where nothing can report. Tightening the watermark toward the gate
' collapses that separation: a fixture slightly heavier than the test journey would
' start queuing in production at the count the gate calls a regression. The gap is
' the design. Full argument: docs/adr/0031-task-thread-ceiling.md.
'
' THE BOUND, which is why the ceiling is safe rather than merely hopeful:
'
' watermark (50) + main-thread floor (< 20) < Roku's cap (100)
'
' Every launch prunes, so the count is never stale and there is no audit interval
' to overshoot. The main-thread floor is the only unseen term, it is a fixed
' bootstrap set, and 50 leaves it 50 threads of room. `tasks.spec.bs` asserts the
' inequality so a future edit to either constant cannot quietly break it.
'
' ── Past the watermark, a launch WAITS (ADR 0041) ───────────────────────────
'
' Above the watermark a launch is queued, not refused: `launchTask()` returns true
' and the node starts when a slot frees. A refusal left whatever the caller set
' before launching (a spinner, a pending screen-load fill, an in-flight flag)
' stuck for the life of the screen, and nearly no call site handled it. Waiting
' makes a saturated app slow instead of broken, and no caller has to know.
'
' The queue is the `TaskLaunchQueue` node on `m.global`, created by
' setGlobalNodes(). It drains from a render-thread Timer that runs only while
' something waits; nothing observes a Task's `state` (see the ADR for why). A retry
' that finds no free slot backs off, up to TASK_QUEUE_RETRY_MAX_SECONDS, and the
' first launch that starts resets it.
' While anything waits, every new launch queues behind it, so the order is FIFO.
' A node STOPped while it waits is dropped, not started, which shows only for a
' node that had never run, whose `state` moves init -> stop. A node that had
' already finished reads "stop" either way, so releasing one of THOSE while it
' waits cannot be seen, and it runs late.
'
' Refusal remains, returning false and starting nothing (the contract an invalid
' node already had): past TASK_QUEUE_CAP waiting launches (only a runaway fan-out
' gets there), or before setGlobalNodes() has created the queue.
'
' ── What the readout is for ────────────────────────────────────────────────
'
' Measuring on a sideloaded dev build, in the same port-8085 console workflow as
' `m.global.debug.*` (docs/dev/debug-flags.md).
' ============================================================================
' Live Task threads above which `launchTask()` queues instead of starting. See the bound above.
const TASK_THREAD_WATERMARK = 50
' Launches that may wait at once; past it `launchTask()` refuses. A waiting node costs
' ~4 KB (measured 2026-09-23 on a 512 MB Streaming Stick 3600X: 3.6-4.1 KB for a
' LoadItemsTask with small inputs), so the full queue is ~2 MB there. ADR 0041.
const TASK_QUEUE_CAP = 500
' How soon the queue first retries its head, and the longest it waits between retries
' while no slot frees. A refused retry costs render-thread time, and a busy render
' thread also slows the Tasks the queue waits on, so retries back off while nothing can
' start. The longest wait is also how late a waiting launch can notice a freed slot.
' Measured 2026-09-23 on a 512 MB Streaming Stick 3600X at the watermark
' (TaskLedgerBench `drainBlocked`): a refused retry costs 2.1 ms reading the ledger
' only, and cost 2.8 ms when it asked admitTaskLaunch, which also writes it twice.
const TASK_QUEUE_RETRY_MIN_SECONDS = 0.1
const TASK_QUEUE_RETRY_MAX_SECONDS = 0.4
' Starts `node`'s Task thread through the accounted chokepoint.
'
' @param node - the Task node to start
' @return true when the node was started, or queued to start once a thread slot frees
' (ADR 0041); false when nothing will start: an invalid node, or a launch over
' the watermark with no room to queue (a full queue, or none yet). Check it
' where state depends on the run delivering. A "loading" flag that only the
' delivery clears must take this result, or a refused launch
' leaves it set for good. A queued run has not started yet: ask
' `isTaskInFlight(node)`, not `node.state`, whether it is still going.
function launchTask(node as object) as boolean
if not isValid(node)
' Behaviour change worth stating: a raw `m.someTask.control = "RUN"` on an
' invalid node FAULTED. This returns false instead, so a missing Task node is
' a screen that never loads rather than an app that dies: better for the
' user, worse for whoever has to find it. Debug builds say it out loud so the
' signal the fault used to provide is not gone.
#if debug
print "[TASKS] launchTask() called with an invalid node — nothing started"
#end if
return false
end if
' Something already waits: queue behind it, even if a slot is free right now, or a
' steady stream of new launches could keep the waiting ones from ever starting.
queue = taskLaunchQueue()
if isValid(queue) and m.global.taskLaunchQueued > 0 then return enqueueTaskLaunch(queue, node)
if admitTaskLaunch(node)
node.control = "RUN"
return true
end if
if isValid(queue) then return enqueueTaskLaunch(queue, node)
noteTaskLaunchRefused(node)
return false
end function
' The launch queue on `m.global`, or invalid before setGlobalNodes() has created it.
function taskLaunchQueue() as dynamic
store = m.global
if not isValid(store) then return invalid
return store.taskLaunchQueue
end function
' Hands `node` to the queue; false (a refusal) only when the queue is full.
function enqueueTaskLaunch(queue as object, node as object) as boolean
if queue.callFunc("enqueue", node) then return true
noteTaskLaunchRefused(node)
return false
end function
' True while `node`'s run is live or waiting in the launch queue. Use it where a caller
' decides "is my run still going?": reading `state = "run"` alone calls a queued run
' finished, and replacing it sends the new one to the back of the queue.
function isTaskInFlight(node as dynamic) as boolean
if not isValid(node) then return false
if LCase(node.state) = "run" then return true
queue = taskLaunchQueue()
if not isValid(queue) or m.global.taskLaunchQueued = 0 then return false
return queue.callFunc("isQueued", node)
end function
' A launch that starts nothing: past the queue's cap, or with no queue yet.
sub noteTaskLaunchRefused(node as object)
' Loud in debug, silent in production: `m.log.*` is stripped and `#if debug` is
' excluded in a store build, so there is no production channel to say this on.
' That is exactly why the RTA peak gate exists: this must be caught before ship,
' not diagnosed after it.
#if debug
print "[TASKS] REFUSED launch of "; node.subtype(); " — over the "; TASK_THREAD_WATERMARK; "-thread watermark with no room to queue"
#end if
#if perfTiming
' The only durable trace a refusal leaves. The print above is `#if debug`, and the
' committed manifest ships `debug=false`, so seeing a refusal that way costs a const
' flip and a rebuild, by which time you are no longer in the state that produced it.
' `perfTiming` ships TRUE in that same manifest and is in harden-prod-manifest.js's
' FORCED_OFF list, so these two fields are present in every dev sideload, absent from
' every store build, and readable from the port-8085 console with no rebuild at all:
'
' ?m.global.taskLedgerRefusals
' ?m.global.taskLedgerFirstRefused
store = m.global
if not isValid(store) then return
if not store.hasField("taskLedgerRefusals") then store.addField("taskLedgerRefusals", "integer", false)
if not store.hasField("taskLedgerFirstRefused") then store.addField("taskLedgerFirstRefused", "string", false)
store["taskLedgerRefusals"] = store["taskLedgerRefusals"] + 1
' FIRST, not most recent. The node that tipped the app over is the one that names
' the fan-out; every refusal after it is a consequence, so overwriting would replace
' the culprit with its victims.
if store["taskLedgerFirstRefused"] = "" then store["taskLedgerFirstRefused"] = node.subtype()
#end if
end sub
' Starts every waiting launch the ledger now admits, oldest first. Called by
' TaskLaunchQueue's drain tick. Entries whose node was released while waiting are
' dropped first (taskQueueDropReleased); the first head the ledger refuses stops the
' pass, since nothing behind it may jump the order.
'
' A tick with no free slot only reads the ledger. It is the tick that repeats while the
' app is saturated, and asking admitTaskLaunch would also write the ledger twice.
'
' @return an AA: `waiting`, the entries still waiting; `started`, how many launches started
function drainTaskLaunchQueue(queue as object) as object
waiting = taskQueueDropReleased(queue)
started = 0
if waiting.count() = 0 or not taskLedgerHasRoom(sharedTaskLedger()) then return { waiting: waiting, started: started }
while waiting.count() > 0
if not admitTaskLaunch(waiting[0].node) then exit while
entry = waiting.shift()
entry.node.control = "RUN"
started++
end while
return { waiting: waiting, started: started }
end function
' The ledger `launchTask()` keeps on `m.global`, or invalid when there is none yet.
function sharedTaskLedger() as dynamic
store = m.global
if not isValid(store) then return invalid
' Bracket access: a dynamically-added field is invisible to the typed node interface.
return store["taskLedger"]
end function
' ── A new node per run ──────────────────────────────────────────────────────
'
' A run that can restart while the previous one is in flight gets a NEW Task node, never a
' STOP then relaunch of the same one: while the old function still runs, that relaunch is
' sometimes silently ignored and the work never happens (docs/architecture/threading.md,
' "Measured findings"; the `no-same-node-relaunch` plugin makes it a build error). A new
' node always starts, the stopped function makes no further progress, and its thread hands
' back its slot at once even while parked in a wait (gated in TaskRelaunch.spec.bs), so
' replacing the node does not stack threads.
'
' Replacing the node brings a second obligation. Roku documents `unobserveField` only as
' removing the connection; it says nothing about a change event already queued to this
' thread. Such an event can reach the handler AFTER the field points at the replacement,
' and a handler that reads `m.task` would then read the new run's still-empty output,
' and usually unobserve it, losing the real delivery. So a handler for a replaceable
' node takes its event and checks `isCurrentTaskEvent` first.
' Stops `node`'s run, if any, and detaches the observer the caller registered on
' `observedField`.
'
' Called from the handler of the field the Task writes, it can land before the Task's
' function returns (the node still reads `run` there), and a stopped run executes nothing
' further. So a caller that releases on delivery relies on that field being the Task's LAST
' write; anything the Task does after it may silently not happen.
'
' @return invalid, so dropping the node for good reads `m.task = releaseTask(m.task, "field")`
function releaseTask(node as dynamic, observedField as string) as dynamic
if isValid(node)
node.unobserveField(observedField)
node.control = "STOP"
end if
return invalid
end function
' Releases `existing` (see releaseTask) and returns a NEW `nodeType` Task node with
' `fields` set, ready to be observed and launched. Always assign the result back:
' `m.task = replaceTask(m.task, "LoadItemsTask", "content", { itemsToLoad: "x" })`.
'
' @param existing - the node of the run being replaced, or invalid when there is none
' @param nodeType - the Task component to create
' @param observedField - the field the caller observes on `existing`
' @param fields - inputs for the new node, set in one call
function replaceTask(existing as dynamic, nodeType as string, observedField as string, fields as object) as object
releaseTask(existing, observedField)
task = CreateObject("roSGNode", nodeType)
task.setFields(fields)
return task
end function
' True when `event` came from `current`, the run the component still wants. False for an
' event queued by a node that has since been replaced or released, which the handler must
' ignore rather than read `current` in its place.
'
' @param event - the roSGNodeEvent the observer handler received
' @param current - the field holding the node of the run in flight (may be invalid)
function isCurrentTaskEvent(event as object, current as dynamic) as boolean
if not isValid(current) then return false
return current.isSameNode(event.getRoSGNode())
end function
' Records `node` and decides whether its launch may proceed.
'
' Returns false when the app is at or above the watermark, in which case the node is
' not recorded: a launch that has not started holds no thread, so counting it would
' inflate the ledger on exactly the path that must not spiral. False is not a refusal:
' launchTask() then queues the node, and the queue's drain asks again once
' taskLedgerHasRoom says a slot has freed.
function admitTaskLaunch(node as object) as boolean
' `m.global`, not `GetGlobalAA()`. That is the whole design, and it was arrived
' at the wrong way round: GetGlobalAA is ~500x cheaper (an append there is below the
' measurement floor; the m.global round-trip is 555.7 us per launch at depth 10 on a
' Stick 4K) and it CANNOT be used, because it is scoped per COMPONENT, not per
' thread. Measured: launching and counting inside one component reads 1, while two
' components on the same render thread read each other as 0
' (tests/rta/specs/gaa-thread-scope.spec.js). A per-component ledger counts only its
' own component's launches, which is not a thread budget.
'
' So the ledger pays for the one property nothing else offers: being shared by every
' component in the app. There is no cheaper shared home: a node field is the only
' cross-component storage SceneGraph has, and reading one returns a COPY, so the
' read-modify-WRITE is unavoidable.
store = m.global
' Bootstrap launches before `m.global` exists must not be blocked: refusing there
' would break startup to protect against a condition startup cannot be in.
if not isValid(store) then return true
#if perfTiming
' Measures the ceiling's OWN cost on a real screen load, the way
' `cellLoadInstrumentUs` does, so "what does this add to ItemDetails" is a
' reading rather than a benchmark multiplied by a guessed launch count. Measured
' 2026-08-23 on a Stick 4K: 7-8 launches, 2.6 ms. Stripped from dev and prod;
' `perfTiming` is in harden-prod-manifest.js's FORCED_OFF list.
if not store.hasField("taskLedgerUs") then store.addField("taskLedgerUs", "float", false)
if not store.hasField("taskLedgerLaunches") then store.addField("taskLedgerLaunches", "integer", false)
' Cached, never per-call: a fresh roTimespan costs ~22 us, five times the thing it
' would be timing (measured 2026-08-23; see cellLoad.bs).
if not isValid(m.taskLedgerProbe) then m.taskLedgerProbe = CreateObject("roTimespan")
m.taskLedgerProbe.mark()
#end if
candidate = recordTaskLaunch(store, node)
#if perfTiming
store["taskLedgerUs"] = store["taskLedgerUs"] + m.taskLedgerProbe.totalMicroseconds()
store["taskLedgerLaunches"] = store["taskLedgerLaunches"] + 1
#end if
if candidate.count() <= TASK_THREAD_WATERMARK then return true
' Not admitted: drop the entry added above, or every turn-away would ratchet the
' count permanently upward. A never-launched node reports `state = "init"`, which
' counts as live, so it would never be pruned. It is always LAST because
' `pruneTaskLedger` appends it after the liveness filter (also pinned in the spec).
'
' NOTE a RELAUNCH of an already-tracked live node is turned away here too, even
' though it adds no thread: `pruneTaskLedger` de-duplicates it out and re-appends it, so the
' count is unchanged and still over. That is the right answer (the app is at the
' watermark either way), but it is worth knowing before reading a turn-away as "this
' launch was the one too many".
candidate.pop()
store["taskLedger"] = candidate
return false
end function
' ── Pure core ───────────────────────────────────────────────────────────────
' True while `node`'s Task thread is running.
'
' "init" counts: a node told to RUN has not necessarily entered its function
' yet, but it is already holding a thread. A node that has never been launched
' also reports "init", which is why only nodes the ledger has recorded are ever
' passed here.
function taskThreadIsLive(node as object) as boolean
if not isValid(node) then return false
return taskStateIsLive(node.state)
end function
' taskThreadIsLive's rule for a `state` value already in hand: the data of a `state`
' change event, where reading the node again would cost a crossing for the same answer.
function taskStateIsLive(state as dynamic) as boolean
if not isValid(state) then return false
state = LCase(state)
return state = "run" or state = "init"
end function
' Counts the tracked nodes whose Task thread is still running.
'
' This is the app's own launches only: it excludes the main and render threads
' and anything started outside `launchTask()`.
function countLiveTaskThreads(ledger as object) as integer
if not isValid(ledger) then return 0
live = 0
for each tracked in ledger
if taskThreadIsLive(tracked) then live++
end for
return live
end function
' True when `ledger` has a slot for one more thread: admitTaskLaunch admits a node that
' holds no thread yet exactly when this is true. Read-only, unlike admitTaskLaunch, so
' asking costs no ledger write.
function taskLedgerHasRoom(ledger as dynamic) as boolean
return countLiveTaskThreads(ledger) < TASK_THREAD_WATERMARK
end function
' Returns `ledger` with finished threads dropped and `node` recorded.
'
' Pruning at launch (rather than at read) keeps the ledger bounded without
' depending on anyone calling the readout: it holds the tasks live right now
' plus any that finished since the previous launch.
'
' `node` is appended unconditionally and de-duplicated against the existing
' entries, because a persistent Task node can be relaunched many times over its
' life.
function pruneTaskLedger(ledger as object, node as object) as object
kept = []
if isValid(ledger)
for each tracked in ledger
if not taskThreadIsLive(tracked) then continue for
if isValid(node) and tracked.isSameNode(node) then continue for
kept.push(tracked)
end for
end if
' Appended after the liveness filter on purpose: at this point `node.state`
' still reflects its PREVIOUS run, since control = "RUN" has not been issued.
if isValid(node) then kept.push(node)
return kept
end function
' Records `node` into `store`'s ledger, creating the field on first use.
'
' The field is CREATED here rather than assumed to exist, because a write to a
' field an roSGNode does not have is a silent no-op. Measured on device: with the
' field declared at the end of `setGlobalNodes()`, the five Task threads that
' `setGlobalNodes()` starts before that line (the three `ApiTask`s, `ApiQueueTask`
' and `SideEffectTask`) were dropped without a trace, and `printTaskThreads()`
' under-reported by a permanent five. Creating on first use makes the ledger
' correct whatever order bootstrap and launches happen in, so the bug cannot come
' back by someone adding a launch earlier.
'
' `store` is passed in rather than read from `m.global`, so a Rooibos test can drive
' it with a node of its own.
function recordTaskLaunch(store as object, node as object) as object
if not isValid(store) then return []
if not store.hasField("taskLedger") then store.addField("taskLedger", "array", false)
' Bracket access: a dynamically-added field is invisible to the typed node
' interface, so dotted access would be a compile error.
kept = pruneTaskLedger(store["taskLedger"], node)
store["taskLedger"] = kept
' Returns the local rather than re-reading the field: reading a node's array field
' marshals the whole array again, and the ceiling needs this value on every launch.
return kept
end function
' ── Launch queue rules (pure) ───────────────────────────────────────────────
'
' A queue is an array of { node, queuedState } entries, oldest first. `queuedState` is
' the node's `state` when it was queued: a node whose state has moved since was released
' (STOPped) while it waited, or otherwise started, and must not be started again.
' Adds `node` to `queue` (in place) and says what happened:
' "queued" - appended at the back
' "already" - it already waits; its entry is re-armed with its current state, so a
' release-and-relaunch while waiting still starts it once
' "running" - its thread is live; not queued, the same way Roku ignores
' relaunching a running node
' "full" - `cap` entries already wait; not queued
function taskQueueAdd(queue as object, node as object, cap as integer) as string
state = LCase(node.state)
if state = "run" then return "running"
for each entry in queue
if entry.node.isSameNode(node)
entry.queuedState = state
return "already"
end if
end for
if queue.count() >= cap then return "full"
queue.push({ node: node, queuedState: state })
return "queued"
end function
' True when `node` waits in `queue` and has not been released since it was queued.
function taskQueueHolds(queue as object, node as object) as boolean
for each entry in queue
if entry.node.isSameNode(node) then return taskQueueEntryStands(entry)
end for
return false
end function
' Returns `queue` without the entries whose node was released while waiting.
function taskQueueDropReleased(queue as object) as object
kept = []
for each entry in queue
if taskQueueEntryStands(entry) then kept.push(entry)
end for
return kept
end function
function taskQueueEntryStands(entry as object) as boolean
if not isValid(entry.node) then return false
return LCase(entry.node.state) = entry.queuedState
end function
' The wait before the queue's next retry, given the last wait and how many launches the
' last retry started: back to the shortest once one started (the slots are freeing, so
' more may follow), otherwise twice the last, up to the longest.
function taskQueueRetrySeconds(lastWait as float, started as integer) as float
if started > 0 then return TASK_QUEUE_RETRY_MIN_SECONDS
nextWait = lastWait * 2
if nextWait > TASK_QUEUE_RETRY_MAX_SECONDS then return TASK_QUEUE_RETRY_MAX_SECONDS
return nextWait
end function
' ── Debug readout ───────────────────────────────────────────────────────────
#if debug
' Prints the ledger to the BrightScript console (telnet port 8085).
'
' Callable from a paused console the same way as the debug flags:
' printTaskThreads()
' bsc-disable-next-line dead-code keep: debug-console: called from the BrightScript console, nothing in the app calls it
sub printTaskThreads()
ledger = []
if isValid(m.global) and isValid(m.global.taskLedger) then ledger = m.global.taskLedger
print "[TASKS] live="; countLiveTaskThreads(ledger); " tracked="; ledger.count()
for each tracked in ledger
print "[TASKS] "; tracked.subtype(); " id="; tracked.id; " state="; tracked.state
end for
print "[TASKS] (app launches only — excludes main, render, and any thread not started via launchTask)"
end sub
#end if