CesiumGS / CesiumGS/cesium-native
TileLoadInput's pAssetAccessor reference member dangles in CesiumIonTilesetLoader / ITwinRealityDataContentLoader: it binds to a derived-to-base conversion temporary
Nobody has claimed this yet.
- Dominant language
- C++
- Stars
- 627
- Forks
- 279
- PR merge metrics
- No merged PRs in 30d
Description
Summary
TileLoadInput stores pAssetAccessor as a reference member (const std::shared_ptr<CesiumAsync::IAssetAccessor>&). Two aggregated loaders construct a TileLoadInput by passing a derived-typed shared_ptr member directly:
CesiumIonTilesetLoader::loadTileContentpassesthis->_pIonAccessor(ashared_ptr<CesiumIonAssetAccessor>)ITwinRealityDataContentLoader::loadTileContentpassesthis->_pRealityDataAccessor(ashared_ptr<RealityDataAssetAccessor>)
shared_ptr<Derived> and shared_ptr<Base> are distinct types, so the compiler materializes a temporary shared_ptr<IAssetAccessor> for the conversion and the reference member binds to that temporary. A reference member initialized through a constructor parameter gets no lifetime extension ([class.temporary]), so the temporary dies at the semicolon of the construction statement — before the aggregated input is ever used. Every downstream read of loadInput.pAssetAccessor in the aggregated loader (e.g. TilesetJsonLoader::loadTileContent) then goes through a dangling reference to a dead stack slot.
Both call sites are present on current main and in v0.59.0 (where we hit it).
The defect in code
// Cesium3DTilesSelection/include/Cesium3DTilesSelection/TilesetContentLoader.h
struct CESIUM3DTILESSELECTION_API TileLoadInput {
...
const std::shared_ptr<CesiumAsync::IAssetAccessor>& pAssetAccessor; // reference member
...
};
// Cesium3DTilesSelection/src/CesiumIonTilesetLoader.cpp
TileLoadInput aggregatedInput(
loadInput.tile,
loadInput.contentOptions,
loadInput.asyncSystem,
this->_pIonAccessor, // shared_ptr<CesiumIonAssetAccessor> → const shared_ptr<IAssetAccessor>&
this->_pLogger,
loadInput.requestHeaders,
loadInput.ellipsoid);
// equivalent to binding the member to:
// std::shared_ptr<IAssetAccessor>(this->_pIonAccessor) ← unnamed temporary, dies at the ';'
return this->_pAggregatedLoader->loadTileContent(aggregatedInput); // reads the dead slot
The synchronous call does not save it: aggregatedInput itself lives in the enclosing frame, but the reference member's target has sub-statement lifetime — a dead slot inside a live frame.
For contrast, the construction site in TilesetContentManager.cpp passes _externals.pAssetAccessor, a long-lived member of the exact parameter type, so no temporary is created there. The reference-member design silently makes safety depend entirely on each call site.
Observed impact
In ordinary builds the dead slot's bytes usually survive until the read, so the UB passes silently (the pointee is still owned by the member, and the stale control-block pointer "happens to work"). When the slot is reused within the window, a shared_ptr copied from garbage lands in the long-lived TileLoadResult and corrupts the heap on its later destruction — detonating far from the cause (in our case as allocator/FORTIFY aborts during engine teardown), with strong device/stack-layout dependence.
Deterministic reproduction (HWASan)
On an Android arm64 device (Pixel 8, Android 17) with full HWASan instrumentation (-fsanitize=hwaddress):
| Build | Result |
|---|---|
| Uninstrumented, unfixed | 65 map open/close cycles, zero crashes (silent UB every cycle) |
| HWASan, unfixed | deterministic crash on cycle 1, 2/2 runs: stack tag-mismatch, READ of size 8 in shared_ptr<IAssetAccessor>'s copy ctor, reached from TilesetJsonLoader::loadTileContent ← CesiumIonTilesetLoader::loadTileContent |
| HWASan, fixed (named local, below) | 20/20 cycles, zero reports |
Suggested fix
Materialize a named local of the exact parameter type at both call sites so the reference binds to an lvalue that outlives the call:
const std::shared_ptr<CesiumAsync::IAssetAccessor> pIonAccessor =
this->_pIonAccessor;
TileLoadInput aggregatedInput(..., pIonAccessor, ...);
No API change; the named local replaces the conversion temporary one-for-one. We are running this fix in production and are happy to open a PR with both call-site fixes.
Suggested hardening (separate consideration)
Call sites that bind temporaries to TileLoadInput's reference parameters could be caught at compile time by either:
- annotating the constructor's reference parameters with
[[clang::lifetimebound]](warns on binding temporaries), or = delete-ing rvalueshared_ptroverloads of the constructor, or- switching the two
shared_ptrmembers to by-value (the reference members look like a deliberate perf choice forrequestHeadersetc., but the twoshared_ptrs are copied intoTileLoadResultdownstream anyway).
Environment
- cesium-native v0.59.0 (defect confirmed still present on
mainby code inspection, 2026-07-20) - Android NDK clang, arm64-v8a; HWASan via
-fsanitize=hwaddress -fno-omit-frame-pointer
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with TileLoadInput in Cesium3DTilesSelection/include/Cesium3DTilesSelection/TilesetContentLoader.h, then inspect loadTileContent in CesiumIonTilesetLoader.cpp and ITwinRealityDataContentLoader.cpp. Verify both aggregated-input constructions bind pAssetAccessor to a named exact-type local, and use the reported Android HWASan reproduction to confirm the dangling-reference report is gone.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100