import "pkg:/source/roku_modules/log/LogMixin.brs"
' The cell-load ledger — how much work did a screen's CELLS do, and how much of it was waste?
'
' ## Why this exists as a second ledger rather than more fields on `screenLoad`
'
' [`screenReadiness.bs`](screenReadiness.bs) answers "when did this screen become usable
' and when did it stop changing" — a question with a beginning and an end, whose state
' lives on the SCREEN's `m`. This answers a different one: how many times were cells bound,
' and how much network did that cost. Two things force it apart:
'
' 1. **Cells are separate components**, so they cannot reach the screen's `m` at all.
' `screenReadiness`'s own header names this constraint and takes the same way out
' (a field the parent observes) for `ExtrasRowList.contentReady`.
' 2. **The interesting behaviour happens AFTER settle.** A cell rebind storm during
' scrolling is invisible to a ledger that closes when the screen stops loading.
'
' It deliberately keeps every other convention `screenLoad` established: namespaced free
' functions, the `#if perfTiming` gate INSIDE each sub so a shipped build compiles them to
' empty calls, one uniform emit shape so `scripts/measurements.js` needs ONE entry covering
' every screen present and future, and an `instrumentUs` that measures the instrument's own
' footprint rather than arguing about it.
'
' ## Where the counters live, and why the content root
'
' On the RowList / MarkupGrid content root — the same node `initTextureManager` already
' hangs `loadedRowRange` on, and the one every cell already holds a reference to
' (`m.contentRoot`). Three reasons it is the right home and not `m.global`:
'
' - **Thread-local.** All SceneGraph component code runs on the render thread, so a cell
' incrementing a content-root field never crosses a boundary. MEASURED on device rather
' than assumed, on HomeRows' root specifically because a Task also writes that node:
' 100 writes to a locally-created node took 351 us, 100 to the content root 379 us —
' ~3.8 us per write, a ratio of 1.08. A rendezvous would be orders of magnitude.
' ⚠️ An earlier version of this note said `m.global` is "main-thread-owned and WOULD
' cross". That is WRONG and was refuted by measurement 2026-08-23: `m.global` is
' RENDER-owned, so from a cell (render thread) it costs 2.0 us against a local node's
' 1.7 us — no crossing at all. It is 93 us from a Task thread. The content root is still
' the right home, on the two grounds below plus per-screen scoping; only the threading
' rationale was false. See `docs/architecture/threading.md`.
' - **Race-free without any locking.** The render thread is single-threaded, so
' read-modify-write across cells cannot interleave.
' - **Free live readout.** Node fields are readable over ODC, so a probe can watch the
' counters mid-scroll without waiting for an emit. There is no second mechanism.
'
' ## The pop-in half — a race, not a count
'
' `appearances` / `popIns` / `popInsCold` and the `loadMs` trio answer a different question
' from the counters above, and it is the one the texture buffer exists for. The buffer
' (±2 rows, ±1 column) loads a cell's image BEFORE the cell is on screen so the user never
' watches a placeholder turn into a poster. Nothing observed whether it wins that race:
' every counter above measures work DONE, none measures work done IN TIME.
'
' A pop-in is scored across two events, deliberately:
'
' 1. `appeared()` — the cell is on screen. If its image is already there, nothing is
' pending and the race was won. If not, a pop-in is PENDING.
' 2. `loadSucceeded()` — the image arrived. A pending pop-in resolves HERE, which is why
' the pending flag exists rather than a counter bump at step 1.
'
' Resolving at step 2 is what keeps a broken image out of the number. An image that never
' arrives is not pop-in — the user sees a glyph, not a fade-in — and `loadsFailed` already
' counts it. Scoring at appearance time instead would make `ExtrasRowList`, where 117 of
' 140 loads fail against a real server, read as a total buffer failure when the buffer had
' nothing to do with it.
'
' `popInsCold` splits the pop-ins by whether a request was even in flight when the cell
' appeared (`isTextureUnloaded`). Cold means the buffer never started the load — the cell
' came from outside the managed range, so the user outran the buffer's DEPTH. Not-cold
' means the buffer started in time and the network was slower, which more depth cannot fix.
' Two different remedies, one counter apart.
'
' `loadMs` / `loadMsCount` / `loadMsMax` time each request from issue to ready. That is the
' `image_load_time` term in the buffer's own adequacy condition —
' `buffer_depth × time_per_scroll_step >= image_load_time` — and it is measured for EVERY
' load, including buffer preloads that never became visible, which are exactly the ones a
' depth decision needs. `loadMsCount` is carried rather than reusing `loadsSucceeded`
' because the two differ: a "ready" with no matching issue (Roku re-reporting a URI that was
' never re-requested) is a success with no interval to record. Dividing by the wrong count
' would be the inference-by-subtraction this ledger already refuses once.
'
' ## Lifecycle — no per-screen call sites
'
' Bound to the texture-manager lifecycle, which all five cell-bearing screens already drive:
' `initTextureManager` attaches the counters, `hideTextureManager` / `destroyTextureManager`
' emit them. Every content root already receives both, though often from its PARENT
' (`Home` hides HomeRows' root; `ItemDetails` hides and destroys ExtrasRowList's). So
' instrumenting a sixth screen costs nothing here — using the texture manager is the
' opt-in.
'
' ⚠️ **Known limit, stated rather than hidden:** a screen that REPLACES its content root
' starts a fresh set of counters, and the old root's counts die unemitted.
' `BaseGridView` does this at five call sites (refresh, filter change, …) and `SearchRow`
' at two. So a grid's line covers "since the last rebuild", not "since the screen opened".
' Fixable by emitting before the replace; deliberately not done in the first cut because
' it needs five call sites and the limit is visible in the numbers rather than silent.
namespace cellLoad
' Bind triggers. Which one fired is the whole point of splitting them: `content` is a
' cell being recycled onto new data (legitimate), `size` is a re-render caused by a
' layout change alone. MEASURED 2026-08-20 on ItemDetails + Home: 289 binds, ALL of them
' `content` and none `size`. So `bindsFromSize` is expected to be ZERO and is worth
' keeping precisely as a standing invariant — if it ever goes positive, a layout change
' has started re-issuing image requests, which is a regression with no other symptom.
const FROM_CONTENT = "content"
const FROM_SIZE = "size"
' Which path wiped a failure glyph back to the loading state. Both are visible flicker,
' but they are not equally bad and the split is what tells them apart: a `bind` wipe
' lands on a cell whose content is changing anyway, while a `reload` wipe hits a cell
' that is SITTING STILL showing a stable glyph — which is what reads to a user as
' blinking for no reason.
const WIPE_BIND = "bind"
const WIPE_RELOAD = "reload"
' Which mechanism evicted a resident texture. The texture manager has TWO, they answer
' to different dials, and the total cannot tell them apart:
' `range` - the cell fell outside the managed vertical range (loadedRowRange +/- 2
' rows) and renderTracking decided. Driven by how far the user scrolled
' DOWN, and it fires on every screen including grids.
' `window` - the horizontal item window inside a VISIBLE row evicted the cell, which
' only happens on a RowList row holding more than TEXTURE_BUFFER_THRESHOLD
' items. This is the ONLY signal that horizontal windowing ran at all.
' The split exists because the total is dominated by `range` — Home reads ~57 unloads on
' a cell sweep with `Limit: 16`, where no row is long enough for `window` to be reachable
' — so "did raising the per-row limit engage windowing?" is unanswerable from `unloads`.
' Both are emitted rather than one plus the total, so neither is derived by subtraction.
const UNLOAD_RANGE = "range"
const UNLOAD_WINDOW = "window"
' Attach counters to a content root. Called by initTextureManager, so every screen that
' uses the texture manager is instrumented without its own call site.
'
' @param contentRoot - the RowList / MarkupGrid content root. Typed `as object` so
' BrighterScript does not schema-check the dynamic fields below
' against ContentNode's declared interface.
' @param component - the COMPONENT's name, e.g. "homeRows". Same convention as
' `screenLoad.begin` and for the same reason: the screen an operator
' navigated to is established by the nav, not by the app.
sub attach(contentRoot as object, component as string)
#if perfTiming
if not isValid(contentRoot) then return
if contentRoot.hasField("cellLoadBinds")
' Re-attach on an existing root (SearchRow re-inits on new data) — keep the
' counts rather than silently zeroing a session in progress.
return
end if
contentRoot.addFields({
cellLoadComponent: component,
cellLoadBinds: 0,
cellLoadBindsContent: 0,
cellLoadBindsSize: 0,
cellLoadBindsRedundant: 0,
cellLoadLoadsStarted: 0,
cellLoadLoadsFailed: 0,
cellLoadLoadsSucceeded: 0,
cellLoadReloads: 0,
cellLoadUnloads: 0,
cellLoadUnloadsRange: 0,
cellLoadUnloadsWindow: 0,
cellLoadWipesBind: 0,
cellLoadWipesReload: 0,
cellLoadAppearances: 0,
cellLoadPopIns: 0,
cellLoadPopInsCold: 0,
cellLoadPopInsReload: 0,
cellLoadPopInsFirst: 0,
cellLoadLoadMs: 0,
cellLoadLoadMsCount: 0,
cellLoadLoadMsMax: 0,
cellLoadInstrumentUs: 0,
cellLoadEmitted: false
})
#end if
end sub
' One cell bind. `isRedundant` is the cell's own answer to "did I just bind this exact
' item at this exact size again?" — kept on the CELL because its previous bind is
' free to remember there and would cost a node round trip to store here.
sub bind(contentRoot as object, trigger as string, isRedundant as boolean)
#if perfTiming
if not isValid(contentRoot) or not contentRoot.hasField("cellLoadBinds") then return
probe = cellLoad.probe()
probe.mark()
' A bind is a new (cell, item) pairing, so the pop-in question re-opens for the new
' item and closes for the old one: this cell has not appeared carrying THIS item yet,
' and a load still outstanding for the previous item can no longer resolve into a
' pop-in against it.
state = cellLoad.cellState()
state.appearCounted = false
state.popInPending = false
state.loadPending = false
contentRoot.cellLoadBinds = contentRoot.cellLoadBinds + 1
if trigger = cellLoad.FROM_SIZE
contentRoot.cellLoadBindsSize = contentRoot.cellLoadBindsSize + 1
else
contentRoot.cellLoadBindsContent = contentRoot.cellLoadBindsContent + 1
end if
if isRedundant
contentRoot.cellLoadBindsRedundant = contentRoot.cellLoadBindsRedundant + 1
end if
contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
#end if
end sub
' An image request was issued (the poster URI was assigned a real URL). Also starts this
' cell's load clock — see the pop-in section of the header for why the interval matters
' more than the count.
sub loadStarted(contentRoot as object, isReload = false as boolean)
#if perfTiming
if not isValid(contentRoot) or not contentRoot.hasField("cellLoadLoadsStarted") then return
probe = cellLoad.probe()
probe.mark()
contentRoot.cellLoadLoadsStarted = contentRoot.cellLoadLoadsStarted + 1
state = cellLoad.cellState()
state.loadPending = true
' Which PATH issued this request, kept so a pop-in can say whether the buffer had a
' job to do at all — see the three-way split in `loadSucceeded`.
state.loadFromReload = isReload
cellLoad.loadTimer().mark()
contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
#end if
end sub
' The cell LEFT the screen. Ends the appearance episode, so a later return counts as a
' NEW appearance rather than being swallowed by the once-per-bind gate.
'
' 🚨 This is what makes the re-entry race measurable at all, and its absence silently
' hollowed out the metric. A RowList / MarkupGrid does NOT rebind a cell that scrolls off
' and back onto the SAME item — `onItemContentChanged` never fires — so with the gate
' re-arming only on bind, every scroll-back was skipped. Measured on `cellSweepGrid`:
' `binds` 28 against 28 items, i.e. nothing was rebound all sweep, while 7 reloads
' happened; all 7 re-entry races went unevaluated and the ledger still published a
' confident-looking `popInsReload` 1. **The one thing the buffer exists to win was the
' one thing not being counted.**
'
' A pending pop-in is dropped here rather than carried: a cell that appeared without its
' image and then went away was never watched fading in, so a late arrival is not a pop-in.
'
' ⚠️ CALLER'S OBLIGATION: only call this while the texture manager is "active". A
' `renderTracking` of "none" during `init` is a layout recalculation and during `hidden`
' is `visible=false` propagating — neither is a departure, and closing the episode on one
' makes the screen's return score a fresh appearance nobody saw. Both cell components
' gate the call for this; `evaluateTextureState` refuses the same two states already.
' The INVARIANT is what to remember — on a resume with no scrolling, every appearance has
' to be paid for by a bind. Measured before the gate against the demo server: 18
' appearances against 10 binds, all non-pop-ins, inflating the denominator that
' `popIns / appearances` divides by. The counts are that fixture's; the bound is the app's,
' and it is gated in tests/rta/specs/cell-load.spec.js.
'
' ⚠️ Deliberately NOT probed into `instrumentUs`, unlike every other entry point here.
' Measured on `cellSweepGrid` against a real server (.177, n=5, same fixture and identical
' workload in both arms): adding the probe took `instrumentUs` 7371 -> 8411 µs, +1040 µs /
' +14.1%. Absolute µs are that fixture's; the RATIO is the point — for a body that is two
' AA assignments and no node write. That delta IS the probe — an `roTimespan` pair plus a
' render-thread read-modify-write on `cellLoadInstrumentUs` — so probing here would make
' the instrument meaningfully more expensive to report a figure that is almost entirely
' the measurement apparatus. `instrumentUs` is therefore a LOWER BOUND by this much.
sub departed(contentRoot as object)
#if perfTiming
if not isValid(contentRoot) or not contentRoot.hasField("cellLoadAppearances") then return
state = cellLoad.cellState()
state.appearCounted = false
state.popInPending = false
#end if
end sub
' The cell is on screen. One half of the pop-in race; `loadSucceeded` is the other.
'
' `hasTexture` is the caller's read of its own poster's `loadStatus`, and it is
' deliberately NOT trusted alone. A request issued and not yet resolved means the texture
' currently on screen belongs to the cell's PREVIOUS item while `loadStatus` still reads
' "ready" from it — established behaviour, not supposition: `JRRowItem.renderItem`'s
' same-URI shortcut exists precisely because Roku leaves `loadStatus` alone until a new
' request resolves. Trusting it alone would under-count exactly the recycled-cell case
' pop-in is about.
'
' Counted at most ONCE per EPISODE, an episode being bind -> visible -> gone. Ungated
' otherwise, deliberately: an appearance is idempotent within an episode, so calling this
' from both `renderItem` and `onRenderTrackingChanged` cannot double-count, and first
' paint legitimately happens before `activateTextureManager` runs. The episode is closed
' only by `bind` and `departed`, and it is `departed` — not this — that carries the
' state gate keeping a spurious compositor flip from re-arming it.
sub appeared(contentRoot as object, hasTexture as boolean, isCold as boolean)
#if perfTiming
if not isValid(contentRoot) or not contentRoot.hasField("cellLoadAppearances") then return
state = cellLoad.cellState()
if state.appearCounted then return
probe = cellLoad.probe()
probe.mark()
state.appearCounted = true
contentRoot.cellLoadAppearances = contentRoot.cellLoadAppearances + 1
state.popInPending = not (hasTexture and not state.loadPending)
state.popInCold = isCold
state.popInFromReload = state.loadPending and state.loadFromReload
contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
#end if
end sub
' An image request came back failed. `loadsFailed` far exceeding the number of distinct
' broken images is the signature of a retry with no memory of the last failure.
'
' ⚠️ NAMED `loadErrored` RATHER THAN `loadFailed`, and it must stay that way. Rooibos's
' transpiler mistakes a namespaced call whose method name it reads as an assertion for a
' call ON THE TEST SUITE, and rewrites its own bookkeeping against the NAMESPACE instead
' of `m` — `cellLoad.currentAssertLineNumber = 148`, `cellLoad_done()`. That is a
' compile-time rewrite producing code that crashes with "Use of uninitialized variable"
' at runtime, and only in the spec, so the app is fine while the suite reports a crash it
' cannot explain. The emitted LOG FIELD is still `loadsFailed` — the wire format is what
' scripts/measurements.js reads and it is unaffected by this rename.
sub loadErrored(contentRoot as object)
#if perfTiming
' No contentRoot guard, deliberately: the flags below live on the CELL's `m`, not on
' the content root, so their lifetime is independent of it — and `bump` already
' guards the root. Guarding here would skip the clear when the root happens to be
' invalid and leave a stale pop-in to resolve against the next valid one.
state = cellLoad.cellState()
state.loadPending = false
' A broken image is not pop-in and must not resolve one — see the header. It is
' already counted, on the very next line.
state.popInPending = false
bump(contentRoot, "cellLoadLoadsFailed")
#end if
end sub
' An image request came back ready. Its job is not the count itself but the RESIDUAL:
' `loadsStarted - (loadsFailed + loadsSucceeded)` is the number of requests still in
' flight when the session was emitted, and at a quiescent boundary it should be zero.
' Without it, an outstanding request is indistinguishable from a success — every
' "successful loads" figure was an inference subtracting one counter from another, with
' nothing able to check it. See `waitCellsQuiet` in `tests/rta/lib/steps.js`, which
' watches this counter for the same reason it watches `loadsFailed`.
sub loadSucceeded(contentRoot as object)
#if perfTiming
if not isValid(contentRoot) or not contentRoot.hasField("cellLoadLoadsSucceeded") then return
probe = cellLoad.probe()
probe.mark()
contentRoot.cellLoadLoadsSucceeded = contentRoot.cellLoadLoadsSucceeded + 1
state = cellLoad.cellState()
if state.loadPending
state.loadPending = false
ms = cellLoad.loadTimer().totalMilliseconds()
contentRoot.cellLoadLoadMs = contentRoot.cellLoadLoadMs + ms
contentRoot.cellLoadLoadMsCount = contentRoot.cellLoadLoadMsCount + 1
if ms > contentRoot.cellLoadLoadMsMax then contentRoot.cellLoadLoadMsMax = ms
end if
' THREE mutually exclusive buckets, all emitted, none left to be derived. Subtracting
' two counters to get the third is the inference this ledger already refuses once, and
' the buckets carry different remedies:
' cold — no request in flight. The cell arrived from outside the managed range, so
' the user outran the buffer's DEPTH. More depth would help.
' reload — a re-entry request was in flight. The buffer had its job and the network
' was slower than the scroll. Depth cannot fix this one.
' first — the cell's FIRST render was still loading. The data had only just arrived,
' so no buffer could have won: this is first paint, not a buffer failure,
' and it is the bucket that dominates a sweep which OPENS its screen.
if state.popInPending
state.popInPending = false
contentRoot.cellLoadPopIns = contentRoot.cellLoadPopIns + 1
if state.popInCold
contentRoot.cellLoadPopInsCold = contentRoot.cellLoadPopInsCold + 1
else if state.popInFromReload
contentRoot.cellLoadPopInsReload = contentRoot.cellLoadPopInsReload + 1
else
contentRoot.cellLoadPopInsFirst = contentRoot.cellLoadPopInsFirst + 1
end if
end if
contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
#end if
end sub
sub reload(contentRoot as object)
#if perfTiming
bump(contentRoot, "cellLoadReloads")
#end if
end sub
' A resident texture was released. `reason` says which mechanism did it — see UNLOAD_*
' for why the split is the whole point of this counter.
sub unload(contentRoot as object, reason as string)
#if perfTiming
' Inline rather than two `bump` calls, matching `bind`: a total plus its split is one
' event, and bumping twice would clock the instrument's own probe twice for it.
if not isValid(contentRoot) or not contentRoot.hasField("cellLoadUnloads") then return
probe = cellLoad.probe()
probe.mark()
contentRoot.cellLoadUnloads = contentRoot.cellLoadUnloads + 1
if reason = cellLoad.UNLOAD_WINDOW
contentRoot.cellLoadUnloadsWindow = contentRoot.cellLoadUnloadsWindow + 1
else
contentRoot.cellLoadUnloadsRange = contentRoot.cellLoadUnloadsRange + 1
end if
contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
#end if
end sub
' A failure glyph was reset to the loading state. See WIPE_* for why the source matters.
sub glyphWipe(contentRoot as object, source as string)
#if perfTiming
if source = cellLoad.WIPE_RELOAD
bump(contentRoot, "cellLoadWipesReload")
else
bump(contentRoot, "cellLoadWipesBind")
end if
#end if
end sub
' Emit the session's counters and reset them. Called by hideTextureManager (the user
' navigated away — a complete viewing session) and destroyTextureManager (teardown).
' Emitting on BOTH is why `cellLoadEmitted` exists: a screen that is hidden and then
' destroyed must publish one line, not two, and a second line of zeroes would read as a
' real session in which nothing happened.
sub emit(contentRoot as object)
#if perfTiming
if not isValid(contentRoot) or not contentRoot.hasField("cellLoadBinds") then return
if contentRoot.cellLoadEmitted then return
if contentRoot.cellLoadBinds = 0 then return
contentRoot.cellLoadEmitted = true
if not isValid(m.cellLoadLog) then m.cellLoadLog = new log.Logger("CellLoad")
' Two lines for the same reason `screenLoad` splits its own: roku-log's Logger.info
' takes the message plus at most nine arguments once the BSC plugin has spent one on
' the injected pkg path, and exceeding it is NOT a compile error — it faults at
' runtime with &hf1 and drops the app into the debugger. Everything rides in the
' message so the format is one thing to read, and BOTH lines repeat the component
' because `assembleSamples` splits a family on that identity.
component = contentRoot.cellLoadComponent
m.cellLoadLog.info("cell-load binds - component " + component + " binds " + contentRoot.cellLoadBinds.toStr() + " fromContent " + contentRoot.cellLoadBindsContent.toStr() + " fromSize " + contentRoot.cellLoadBindsSize.toStr() + " redundant " + contentRoot.cellLoadBindsRedundant.toStr() + " items " + countItems(contentRoot).toStr())
m.cellLoadLog.info("cell-load popin - component " + component + " appearances " + contentRoot.cellLoadAppearances.toStr() + " popIns " + contentRoot.cellLoadPopIns.toStr() + " popInsCold " + contentRoot.cellLoadPopInsCold.toStr() + " popInsReload " + contentRoot.cellLoadPopInsReload.toStr() + " popInsFirst " + contentRoot.cellLoadPopInsFirst.toStr() + " loadMs " + contentRoot.cellLoadLoadMs.toStr() + " loadMsCount " + contentRoot.cellLoadLoadMsCount.toStr() + " loadMsMax " + contentRoot.cellLoadLoadMsMax.toStr())
m.cellLoadLog.info("cell-load work - component " + component + " loadsStarted " + contentRoot.cellLoadLoadsStarted.toStr() + " loadsFailed " + contentRoot.cellLoadLoadsFailed.toStr() + " loadsSucceeded " + contentRoot.cellLoadLoadsSucceeded.toStr() + " reloads " + contentRoot.cellLoadReloads.toStr() + " unloads " + contentRoot.cellLoadUnloads.toStr() + " unloadsRange " + contentRoot.cellLoadUnloadsRange.toStr() + " unloadsWindow " + contentRoot.cellLoadUnloadsWindow.toStr() + " wipesBind " + contentRoot.cellLoadWipesBind.toStr() + " wipesReload " + contentRoot.cellLoadWipesReload.toStr() + " instrumentUs " + contentRoot.cellLoadInstrumentUs.toStr())
reset(contentRoot)
#end if
end sub
' Zero the counters for the next session. `cellLoadEmitted` is cleared LAST so a hidden
' screen that is shown again starts a fresh, emittable session.
sub reset(contentRoot as object)
#if perfTiming
if not isValid(contentRoot) or not contentRoot.hasField("cellLoadBinds") then return
contentRoot.setFields({
cellLoadBinds: 0,
cellLoadBindsContent: 0,
cellLoadBindsSize: 0,
cellLoadBindsRedundant: 0,
cellLoadLoadsStarted: 0,
cellLoadLoadsFailed: 0,
cellLoadLoadsSucceeded: 0,
cellLoadReloads: 0,
cellLoadUnloads: 0,
cellLoadUnloadsRange: 0,
cellLoadUnloadsWindow: 0,
cellLoadWipesBind: 0,
cellLoadWipesReload: 0,
cellLoadAppearances: 0,
cellLoadPopIns: 0,
cellLoadPopInsCold: 0,
cellLoadPopInsReload: 0,
cellLoadPopInsFirst: 0,
cellLoadLoadMs: 0,
cellLoadLoadMsCount: 0,
cellLoadLoadMsMax: 0,
cellLoadInstrumentUs: 0,
cellLoadEmitted: false
})
#end if
end sub
' The instrument's own stopwatch, created ONCE per cell and reused.
'
' `m` here is the CALLING component's — these are namespaced free functions, the same
' mechanism `screenReadiness.bs` relies on — so this caches on the cell, which is
' exactly the right lifetime: one per pooled cell rather than one per event.
'
' Measured before this existed, on a Home load: 15458 us across ~706 counter calls, or
' ~22 us each, against ~3.8 us for the field write the call is actually making. So the
' STOPWATCH was five times the cost of the thing it was timing. `screenReadiness.bs`
' warns about precisely this ("a fresh roTimespan per ledger call would make the
' measurement of the overhead a significant part of the overhead") and this repeated it
' anyway; `instrumentUs` is what caught it, which is the argument for carrying that field
' at all.
function probe() as object
if not isValid(m.cellLoadProbe) then m.cellLoadProbe = CreateObject("roTimespan")
return m.cellLoadProbe
end function
' The cell's LOAD clock, distinct from `probe()` because it spans a network round trip
' rather than a single call — one `roTimespan` cannot carry two marks.
function loadTimer() as object
if not isValid(m.cellLoadLoadTimer) then m.cellLoadLoadTimer = CreateObject("roTimespan")
return m.cellLoadLoadTimer
end function
' Per-CELL instrument state, cached on the caller's `m` exactly as `probe()` is.
'
' One AA rather than four bare `m` fields so every flag is a real boolean from its first
' read. An unset `m.someFlag` is Invalid, and `if Invalid` is a &h18 type mismatch rather
' than a falsy check — the fault class behind the `EnableFallbackFont` crash. Initialising
' the whole set together makes that unreachable by construction instead of by every call
' site remembering a guard.
function cellState() as object
if not isValid(m.cellLoadState)
m.cellLoadState = {
loadPending: false,
loadFromReload: false,
popInPending: false,
popInCold: false,
popInFromReload: false,
appearCounted: false
}
end if
return m.cellLoadState
end function
' Total ITEMS under a content root, which is the denominator of the rebind rate and is
' NOT `getChildCount()`. The two content shapes disagree about what a child is: a
' MarkupGrid root is flat, so its children ARE the items, while a RowList root is two
' level and its children are ROWS. Using the child count for both would have divided
' Home's binds by its ~10 rows instead of its ~68 items and published a "rebind rate"
' an order of magnitude wrong — in a field that looks equally plausible either way.
'
' Detected by shape rather than by a flag the caller has to pass correctly: a root whose
' first child has children of its own is hierarchical. Walked once per emit, never per
' bind.
function countItems(contentRoot as object) as integer
rows = contentRoot.getChildCount()
if rows = 0 then return 0
first = contentRoot.getChild(0)
if not isValid(first) or first.getChildCount() = 0 then return rows
total = 0
for i = 0 to rows - 1
child = contentRoot.getChild(i)
if isValid(child) then total = total + child.getChildCount()
end for
return total
end function
' Increment one counter. Extracted so the single-counter call sites above do not each
' repeat the guard, and so the instrument's own cost is accumulated in one place rather
' than in each of them, where it could drift. A counter that bumps a TOTAL and a SPLIT
' together (`bind`, `unload`) does it inline instead — one event should clock the probe
' once, not twice. Deliberately no call-site COUNT here: the number this comment used to
' carry said nine against an actual four, which is what a hand-maintained enumeration
' does. `grep -n 'bump(contentRoot' source/utils/cellLoad.bs` answers it.
sub bump(contentRoot as object, field as string)
#if perfTiming
if not isValid(contentRoot) or not contentRoot.hasField(field) then return
probe = cellLoad.probe()
probe.mark()
contentRoot.setField(field, contentRoot.getField(field) + 1)
contentRoot.cellLoadInstrumentUs = contentRoot.cellLoadInstrumentUs + probe.totalMicroseconds()
#end if
end sub
end namespace