fix(FragmentsModels): don't crash when a shell needs more render buffers than predicted - #306
iamahsanmehmood wants to merge 4 commits into
Conversation
…ers than predicted A shell (one densely-triangulated mesh) whose vertex/index data exceeds limitOf2Bytes (65,536, a uint16-range unit) gets split across multiple internal TileData buffers. Two independent passes decide how many buffers a shell needs: ShellTemplateConstructor.manageMemory (a sizing pass, run ahead of time to pre-allocate the TileData[] array) and ShellConstructor.manageMemory (the real construction pass, run later against the actual geometry). Nothing enforces that these two formulas always agree - if the construction pass ever needs one more buffer than the sizing pass predicted, setTileData read past the end of the pre-built array (bufferGeometries[this._indices] on an array too short), producing undefined, and the very next call (initializeIndices's this._tileData.indexCount!) threw 'Cannot read properties of undefined (reading indexCount)' on every render frame - a model that would never finish loading. ShellConstructor now creates a buffer on demand (createOverflowTileData, sized at the same limitOf2Bytes cap the whole scheme targets) instead of reading past the array, and trims it down to the real final index/position/normal counts once its construction completes (finalizeCurrentIfDynamic) - downstream code (VirtualTilesController.setupTileSampleAttributes) treats those counts as authoritative when copying a shell's data into the merged render tile, so the safe-upper-bound allocation can't be left as the reported size. Extensive empirical testing (large synthetic grid meshes, both ShellType.NONE and ShellType.BIG, matching the triangulated-Face3-only shape a Fragments exporter like openskp actually produces) found no real-world case where the two passes' formulas genuinely disagree at production scale, so the exact organic trigger for the reported crash remains unconfirmed. This fix is a direct, always-safe guard against that failure MODE regardless of what causes it - tests verify the defensive mechanism itself by artificially truncating a correctly- predicted buffer array before construction, the same failure shape a genuine divergence would produce.
…ffer test The 'handles multiple consecutive under-predicted buffers' test built a 250x200 synthetic grid shell via the FlatBuffers builder - fast enough locally (~2.6s) but slow enough on a CI runner to exceed vitest's default 5000ms test timeout, failing the whole check even though the actual fix logic (117/118 other assertions) passed fine. 200x200 already produces the same 4 predicted buffers this test needs to drop 3 of, at noticeably less construction cost. Also gave both large- grid tests an explicit, generous timeout as a safety margin against CI-runner speed variance going forward.
|
Thanks for the report and the test. The crash is real and your test does fail on current main with exactly the reported error, so the repro is solid. Before we take it, though: this guards the symptom rather than closing the gap. The two sizing passes still disagree, and when the second pass needs an extra buffer it means the first one under-filled one of its own and left it with a too-large One practical note too: the 200x200 fixture takes around 12 seconds on its own and times out under parallel load, and it starves an unrelated tiles-controller test into a timeout as well. It needs to shrink or move out of the default run. |
Both slow tests in this file need the same 200x200 grid shell (one needs >=2 predicted buffers, the other >=4), but each built its own independently - the actual reported problem (maintainer feedback on this PR): ~12s for the fixture on a slower CI runner, and slow enough under parallel load to starve an unrelated tiles-controller test into timing out too. Shrinking the grid further isn't really available without changing what's being tested: reaching >=4 predicted buffers needs roughly 32,768+ triangles no matter how they're arranged (buffer limit is 65,536 points, ~3 points/triangle via ShellFace3's fast path), so 200x200 is already close to the minimum for that test's own guarantee. Building the shell and running the sizing pass once in beforeAll and sharing the result (each test still makes its own local copy before truncating/constructing, so there's no cross-test mutation) cuts the actual expensive part - thousands of individual FlatBuffers profile/ point writes - from twice to once. Total test-file time for the three specs: ~4.2s (down from the ~12s the slow one alone previously took). Verified: all 3 tests still pass, tsc --noEmit and eslint both clean.
|
Pushed Test timeoutBoth slow tests needed the same 200x200 fixture (one needs ≥2 predicted buffers, the other ≥4) but each built it independently - that duplication was the actual cost, not the buffer-count requirement itself. Reaching ≥4 buffers needs roughly 32,768+ triangles no matter how they're arranged (buffer limit is 65,536 points, ~3 points/triangle via Built the shell and ran the sizing pass once in Digging into the actual divergenceWent through every vertex-accounting code path that contributes to either pass's buffer-overflow check, line by line, rather than relying on empirical testing again:
I couldn't find an arithmetic mismatch in any of these - which, combined with the original PR's own empirical testing across large synthetic grids ( |
|
Thanks for the thorough pass, and for getting the test file from 12 seconds down to under 3. A static read of every accounting path coming back clean is useful in itself: it tells us the divergence is not in the arithmetic for the shapes our importer produces, which points at the input. Since you opened #307 from a real crash and the geometry mirrors OpenSKP's export, could you send us the .frag that crashed, or the source model plus the exporter version that produced it? Please send it to antonio@thatopen.com. With the real file we can reproduce the divergence and make both passes agree, which is the fix we would like to land rather than the guard alone. |
|
To clarify where this came from: this surfaced through real production use, not a synthetic benchmark. We build FrameSmart on top of OpenSKP, and once we integrated this rendering path, a number of real client files were failing to load with exactly this crash - large, densely-triangulated meshes hitting the divergence between the two buffer-count passes. We root-caused it to the mismatch described in #307, fixed it as in #306, and confirmed in production that those same files - and large files generally - now load and render correctly. I can't share the actual files - they're private client data - which is why #307 doesn't have one attached. I only had a synthetic, constructed reproduction ready to publish (simulating the under-predicted buffer array directly) rather than a specific minimal mesh that trips it organically, and didn't isolate one before opening the issue. If it's useful for landing a fix at the root rather than the guard, I'm glad to try building a representative OpenSKP-exported model at a similar scale/topology to what we saw fail, since I can't hand over the real ones. Let me know if you have a sense of what shape is most likely to expose the divergence (single dense shell vs. many smaller ones, a particular triangle/vertex ratio) - that would narrow the search a lot. |
Closes #307
Summary
A shell (one densely-triangulated mesh) whose vertex/index data exceeds
limitOf2Bytes(65,536, auint16-range unit) gets split across multiple internalTileDatabuffers. Two independent passes decide how many buffers a shell needs:ShellTemplateConstructor.manageMemory- a sizing pass, run ahead of time to pre-allocate theTileData[]arrayShellConstructor.manageMemory- the real construction pass, run later against the actual geometryNothing enforces that these two formulas always agree. If the construction pass ever needs one more buffer than the sizing pass predicted,
setTileDataread past the end of the pre-built array (bufferGeometries[this._indices]on an array too short), producingundefined, and the very next call (initializeIndices'sthis._tileData.indexCount!) threw:on every render frame - a model that would never finish loading, with the
.fragfile itself perfectly valid (metadata-only reads succeed fine; this is purely a render-geometry-construction bug). Originally surfaced by a production model (via a downstream exporter change that finally produced a shell large enough to exercise this code path for the first time).Fix
ShellConstructornow creates a buffer on demand (createOverflowTileData, sized at the samelimitOf2Bytescap the whole scheme targets) instead of reading past the array, and trims it down to the real final index/position/normal counts once its construction completes (finalizeCurrentIfDynamic). This matters beyond just avoiding the crash: downstream code (VirtualTilesController.setupTileSampleAttributes) treatsindexCount/positionCount/normalCountas authoritative exact counts when copying a shell's data into the merged render tile, so leaving the safe-upper-bound allocation as the reported size would silently corrupt that copy.Honesty note on root cause: extensive empirical testing (large synthetic grid meshes, both
ShellType.NONEandShellType.BIG) found no real-world case where the two passes' formulas genuinely disagree at production scale, so the exact organic trigger for the originally-reported crash remains unconfirmed. This fix is a direct, always-safe guard against the failure mode regardless of what causes it, not a fix to a pinned-down formula bug. Tests verify the defensive mechanism itself by artificially truncating a correctly-predicted buffer array before construction - the same failure shape a genuine divergence would produce - including a multi-buffer-drop case that caught a real off-by-one in an earlier version of this fix (this._indices's meaning shifts by one across call sites oncenextBuffer's own trailing incrementer has run; fixed by tracking_currentTileIndexexplicitly instead).Test plan
shell-constructor.test.ts: 3/3 passing - crash reproduction via simulated under-prediction (single and multi-buffer), plus a no-regression check that untouched (correctly Pass-1-sized) buffers are never modifiednpx tsc --noEmit: cleannpx eslint: cleanmain; confirmed no conflicts and tests still pass