source_utils_tasks.bs

' ============================================================================
' 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