' ============================================================================
' 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 — i.e. 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 simply 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 on a Stick 4K (render thread), and it is the cheapest CORRECT option
' available — 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 — 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 — 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 / debug shell split ──────────────────────────────────────────
'
' 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 reads and writes
' `m.global.taskLedger` or prints, and the shell is `#if debug`.
'
' The split is not stylistic. Test builds compile against the same `manifest`,
' where `bs_const=debug=false`, so anything inside `#if debug` is stripped
' before Rooibos ever sees it — a fully-gated ledger would be untestable. Same
' reason `apiPipeline.bs` and `apiPromise.bs` are split this way; see
' docs/architecture/async.md.
'
' A production build pays nothing at runtime. `#if debug` survives transpile as a
' BrightScript conditional-compilation directive and is resolved on the device
' from the manifest's `bs_const=debug=false`, so the shell — and `launchTask()`'s
' call into it — is excluded at load rather than merely skipped. Same mechanism
' as the DebugFlags precedent in globals.bs. The pure functions remain, but with
' nothing calling them they cost only their own bytes.
'
' ── The ceiling, and why these two numbers ─────────────────────────────────
'
' `launchTask()` REFUSES above TASK_THREAD_WATERMARK live threads. Roku's cap is
' 100. Measured peak in real use is 11 (13-library server, Stick 4K, sampled
' across a scripted journey — 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, deliberately kept apart:
'
' 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 refusing 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 refusal 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.
'
' Refusal returns false and starts nothing. That is the SAME contract an invalid
' node already had, so no call site learns a new failure mode — and a screen that
' loses a row beats an app that dies at 100 threads. It is deliberately not a
' crash: #728 is closed, so there is no live crash whose diagnostic value we would
' be giving up.
'
' ── What the readout is for ────────────────────────────────────────────────
'
' Measuring on a sideloaded dev build, in the same port-8085 console workflow as
' `m.global.debug.*` — see docs/dev/debug-flags.md.
' ============================================================================
' Live Task threads at which `launchTask()` starts refusing. See the bound above.
const TASK_THREAD_WATERMARK = 50
' Starts `node`'s Task thread through the accounted chokepoint.
'
' @param node - the Task node to start
' @return true when the node was valid and RUN was issued, false otherwise. No
' call site checks this today; see the invalid-node note below before
' deciding you want to.
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 simply gone.
#if debug
print "[TASKS] launchTask() called with an invalid node — nothing started"
#end if
return false
end if
if not admitTaskLaunch(node)
' 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(); " — at or above the "; TASK_THREAD_WATERMARK; "-thread watermark"
#end if
return false
end if
node.control = "RUN"
return true
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 refused launch starts no thread, so counting it would inflate the
' ledger on exactly the path that must not spiral.
function admitTaskLaunch(node as object) as boolean
' `m.global` and 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 — 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
' Refused: drop the entry we just added, or every refusal 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 refused 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 refusal as "this
' launch was the one too many".
candidate.pop()
store["taskLedger"] = candidate
#if perfTiming
' THE ONLY DURABLE TRACE A REFUSAL LEAVES. The print below 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
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 the watermark 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
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
state = LCase(node.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
' 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 gets relaunched many times over its
' life (`ExtrasRowList` alone relaunches its nodes 38 times).
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 this stays outside the
' `#if debug` shell and a Rooibos test can drive it — test builds compile with
' `debug=false`, so anything inside the gate is unreachable from Rooibos.
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
' ── Debug shell ─────────────────────────────────────────────────────────────
#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()
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