fix(places): use-after-free and NaN centroid in RegionGrowingTraversabilityClustering#152
Conversation
mergeRegions held a reference (neighbor_id) into region.neighbors, but region.merge() erases it, leaving a dangling reference read by the subsequent graph.removeNode() call (heap-use-after-free). Copy it by value first. Also guard computeBoundary against an empty exterior boundary, which produced a NaN centroid/place position. Upstream: Hydra PR MIT-SPARK#152.
…bilityClustering Two heap/correctness bugs in the region-growing traversability clustering, found with AddressSanitizer. 1. Use-after-free / iterator invalidation in mergeRegions(). The loop iterates `region.neighbors` while `region.merge()` calls `neighbors.erase(other.id)` on that same map, invalidating the loop iterator and freeing the node the loop's `neighbor_id` refers to (read immediately after by graph.removeNode). Fix the root cause rather than the symptom: Region::merge no longer erases from `neighbors` (it only inserts, which never invalidates iterators), and mergeRegions() now iterates with an explicit iterator and erases the stale and merged-away neighbors in place. 2. NaN centroid in Region::computeBoundary(). `centroid /= exterior_boundary.size()` divides a zero vector by 0 when a region has no exterior boundary, producing a NaN place position. Guard the division.
e4933d7 to
c6563aa
Compare
nathanhhughes
left a comment
There was a problem hiding this comment.
LGTM, thanks for catching this! Some style things that would be helpful
| // Use an explicit iterator so we can erase entries in place. Region::merge | ||
| // only inserts into region.neighbors (std::map insertion never invalidates | ||
| // iterators), so n_it stays valid across the merge; we remove the stale and | ||
| // merged-away neighbors here rather than inside merge(). |
There was a problem hiding this comment.
drop comment, this isn't useful where it is
| // iterators), so n_it stays valid across the merge; we remove the stale and | ||
| // merged-away neighbors here rather than inside merge(). | ||
| for (auto n_it = region.neighbors.begin(); n_it != region.neighbors.end();) { | ||
| const NodeId neighbor_id = n_it->first; |
There was a problem hiding this comment.
minor preference for structured binding like:
const auto [neighbor_id, num_connecting_voxels] = *n_it;
| // Merge neighbor into this region, then remove it from regions_, the | ||
| // graph, and this region's neighbor map (in place, so no iterator is | ||
| // left dangling). |
There was a problem hiding this comment.
Something shorter like "merge neighbor into this region and then cleanup" is probably better
| } | ||
|
|
||
| neighbors.erase(other.id); | ||
| // NOTE: other.id is intentionally NOT erased from `neighbors` here. Erasing it |
There was a problem hiding this comment.
Simpler comment would be better
| // Guard against an empty exterior boundary (e.g. a region with no boundary | ||
| // voxels): dividing a zero vector by 0 yields a NaN centroid/place position. | ||
| if (!exterior_boundary.empty()) { | ||
| centroid /= exterior_boundary.size(); | ||
| } |
There was a problem hiding this comment.
I think I would prefer this check to come earlier, i.e., after exterior_boundary = growRegion(boundary, farthest) I would put something like if (exterior_boundary.empty()) { LOG(WARNING) << "Empty region ecountered!"; } and have the region constructor default the position to something sane (like NaNs tbh). My understanding is that we shouldn't ever need to compute the centroid of a place that doesn't have exterior boundary voxels (and picking zero is a bad choice because you can't distinguish between a valid place at the origin and invalid places)
- drop/shorten comments per review; structured binding for neighbor iteration - warn on empty region right after growRegion, and default Region::centroid to NaN so a boundary-less place stays distinguishable from a valid origin place (rather than guarding the division to leave it at zero)
Mirrors the upstream Hydra PR fixes onto the build branch: - deformation_interpolator: stamp cache entries with the computed timestamp_ns (not last_update_time_ns, which is 0 for objects) and reset to init_pos before transform() so repeated deformation of archived nodes stays idempotent (upstream PR MIT-SPARK#153). - region_growing_traversability_clustering: erase merged/stale neighbors in place with the loop iterator instead of erasing inside Region::merge, removing the dangling-reference hazard (upstream PR MIT-SPARK#152).
These were both discovered while I was working on some of the image keyframing code which does a lot more work in the heap.
Summary:
Two heap/correctness bugs in the region-growing traversability clustering, found with AddressSanitizer.
1. Use-after-free / iterator invalidation in
mergeRegionsmergeRegionsiteratesregion.neighborswhileregion.merge()callsneighbors.erase(other.id)on that same map. That erase invalidates the loop iterator and frees the node the loop’sneighbor_idrefers to, whichgraph.removeNode(neighbor_id)then reads. (ASan: heap-use-after-free via_Rb_treeerase; report available on request.)Fix (root cause, not the symptom):
Region::mergeno longer erases fromneighbors— it only inserts, andstd::mapinsertion never invalidates existing iterators.mergeRegionsnow walksregion.neighborswith an explicit iterator and erases the stale and merged-away neighbors in place (n_it = neighbors.erase(n_it)).2. NaN centroid in
Region::computeBoundarycentroid /= exterior_boundary.size()divides a zero vector by 0 when a region has no exterior boundary, producing a NaN place position. Fix: guard the division (leave the centroid at zero when the boundary is empty).Both fixes verified on infinite corridor bags — the map (agents/places/objects) which were previously crashing.