Pass-start rollback for the progressive loaders (GSplats, Points, Lines,
and the Mesh reveal ladder).
A progressive loader's updateView appends each streamed level to
loadedLODs as soon as the level's data arrives — BEFORE the caller has
concatenated, projected and committed it. That ordering is deliberate (the
concat at the end of the same call reads the array), but it means the
ladder cursor advances on work that has not yet been proven committable.
When the commit then fails, the consequences compound rather than back off:
the retry resumes from the ADVANCED cursor, so it loads the next level
and attempts a strictly LARGER allocation than the one that just failed —
escalation, not backoff, which is exactly wrong when the failure was
RangeError: Array buffer allocation failed;
once the cursor reaches nLods, hasMoreLODs goes false. The refinement
loop then drops the loader from anyHasMoreLODs, the
RefinementFailureTracker never reaches its cap, no "giving up" toast
fires, and the node is stranded PERMANENTLY at its last successfully
committed rung while the data-loading monitor reports the cursor as
"LOD n/n ~100%".
Observed on the hosted cosmicflows_laniakea demo (#2426): basins reported
a complete ladder while rendering a coarse prefix, and the retries that were
supposed to recover the node were the thing driving it further out of
memory.
The fix is to make a failed pass leave the loader exactly as the pass found
it, so a retry re-attempts the SAME prefix. Each loader records the level
count at pass start and exposes a rollbackToPassStart() that its
refinement wrapper calls from its catch. The DECISION lives here as a pure
function so the four loaders cannot drift — the same reason
streaming-policy.ts owns the streaming discipline — while the mutation
stays in the loader, which is the only thing that can touch its private
fields.
COUNTS HERE ARE LOGICAL LEVELS, NEVER PAYLOAD ENTRIES. Every loader now FOLDS
its ladder — the rungs are concatenated into one cumulative payload and the
parts released — so loadedLODs.length becomes 1 while the logical count is
still 7. That is why each loader carries a separate _loadedLODCount, and
why LadderRollbackState takes the two counts apart rather than
inferring either from an array length. Feeding a payload count into a logical
field is silently wrong: no throw, just a memo gate evaluated against the
wrong number.
Pass-start rollback for the progressive loaders (GSplats, Points, Lines, and the Mesh reveal ladder).
A progressive loader's
updateViewappends each streamed level toloadedLODsas soon as the level's data arrives — BEFORE the caller has concatenated, projected and committed it. That ordering is deliberate (the concat at the end of the same call reads the array), but it means the ladder cursor advances on work that has not yet been proven committable.When the commit then fails, the consequences compound rather than back off:
RangeError: Array buffer allocation failed;nLods,hasMoreLODsgoes false. The refinement loop then drops the loader fromanyHasMoreLODs, theRefinementFailureTrackernever reaches its cap, no "giving up" toast fires, and the node is stranded PERMANENTLY at its last successfully committed rung while the data-loading monitor reports the cursor as "LOD n/n ~100%".Observed on the hosted
cosmicflows_laniakeademo (#2426): basins reported a complete ladder while rendering a coarse prefix, and the retries that were supposed to recover the node were the thing driving it further out of memory.The fix is to make a failed pass leave the loader exactly as the pass found it, so a retry re-attempts the SAME prefix. Each loader records the level count at pass start and exposes a
rollbackToPassStart()that its refinement wrapper calls from its catch. The DECISION lives here as a pure function so the four loaders cannot drift — the same reasonstreaming-policy.tsowns the streaming discipline — while the mutation stays in the loader, which is the only thing that can touch its private fields.COUNTS HERE ARE LOGICAL LEVELS, NEVER PAYLOAD ENTRIES. Every loader now FOLDS its ladder — the rungs are concatenated into one cumulative payload and the parts released — so
loadedLODs.lengthbecomes 1 while the logical count is still 7. That is why each loader carries a separate_loadedLODCount, and why LadderRollbackState takes the two counts apart rather than inferring either from an array length. Feeding a payload count into a logical field is silently wrong: no throw, just a memo gate evaluated against the wrong number.