Classic scanmatcher VoxelGrid overflow safety — 2026-08-11
Status: PUBLIC_HUMBLE_JAZZY_CI_PASS_ISSUE_AND_RELEASE_PENDING
Runtime safety commit:
a2368c486fc35c0edcac6d9dbf2f9cb89475c820Component recovery proof commit:
bce5a9dd2f8f1333b92eba5a0ace98f45db58f3bRejected-map-update threshold recovery commit:
99cce93a07a7cc136eb925c446dd705bdcd7b37cPublic issue: #69 — scanmatcher_node-1 process has died
Integration target: Draft PR #427
Issue or release mutations made by this follow-up: none
Decision
The classic scanmatcher must never use PCL's unfiltered-copy fallback as its
overflow behavior. Every explicit PCL VoxelGrid call is now behind one
fail-closed wrapper. Unsafe input clears the candidate output, emits a stable
reason and recovery action, and returns control without replacing the last
valid map or registration target. Valid input still uses PCL with the same
effective float leaf size; the bounded parity test produces exactly equal
XYZ/intensity points.
This closes the local code hazard and both local component-continuation gates. The asynchronous follow-up also snapshots the triggering scan's distance for the worker, locks shared map diagnostics, and joins an outstanding worker before component destruction. The S1 review now commits the movement baseline only after a map update succeeds. A rejected VoxelGrid layout or contained worker exception therefore cannot consume the threshold needed by the next safe scan. It does not by itself close public issue #69: the historical private rosbag was not retained or replayed, the reviewed public CI revision must carry both component cases plus the commit-state regression, and a named release plus an accurate issue response are still required.
Upstream behavior being contained
The official PCL 1.12.1 implementation and 1.14.1 implementation both calculate three grid dimensions, compare their product with signed 32-bit maximum, warn on overflow, assign the input cloud to the output, and return. The warning is therefore not a filtered or rejected result.
Issue #69 records that warning immediately before scanmatcher exited with
-8 (SIGFPE). The original parameters included vg_size_for_map: 0.1 and a
200 m scan range; increasing the leaf size mitigated at least one later user
report but did not provide a safe runtime contract.
The wrapper is deliberately more conservative than the upstream span-only check. It also rejects:
- a requested leaf that is non-finite, non-positive, not representable as a positive finite float, or has a non-finite float inverse;
- a cloud marked
is_dense=truethat contains non-finite XYZ; - an absolute
floor(coordinate / leaf)outside signed 32-bit range; - the exact floored voxel-division product above
2,147,483,647.
All multiplication is bounded before it occurs, so the preflight does not introduce another signed-overflow path.
Call-site behavior
Commit a2368c4 removes every direct pcl::VoxelGrid instance from
scanmatcher_component.cpp and routes all five stages through
filterVoxelGridSafely.
| Stage | Leaf parameter | Rejection behavior | Preserved state |
|---|---|---|---|
initial_map |
vg_size_for_map |
return false; wait for another usable scan |
node, configuration, and publishers remain active |
input_scan |
vg_size_for_input |
skip the scan before registration | current pose, path, map, and registration target |
map_update |
vg_size_for_map |
return from synchronous or asynchronous update | existing map array and registration target |
registration_target |
vg_size_for_input |
do not install the unsafe newly built target | previously installed registration target |
recovery_target |
vg_size_for_input |
return false from target refresh |
existing target and recovery state |
Recurring runtime warnings are throttled to one per five seconds at each macro site. Initial-map refusal is not throttled because it is the immediate reason mapping cannot initialize. No path automatically increases a leaf size or clips coordinates; both would silently change map resolution or data.
Privacy-safe bounded reproducer
The regression fixture contains only these two synthetic points:
[-200.0, -200.0, -10.0]
[ 200.0, 200.0, 10.0]
With an effective 0.1 m leaf, the conservative divisions are
4001 x 4001 x 201, or 3,217,608,201 cells. This exceeds PCL's signed
32-bit 2,147,483,647 limit and produces
VOXEL_GRID_LAYOUT_OVERFLOW. The wrapper empties a pre-populated output cloud
instead of copying these input points through.
The test is a failure-class reproducer, not a claim that these were the exact bounds in the unavailable historical bag. No bag, map geometry, issue author, comment author, local path, or private sensor data was copied into the repository.
Automated coverage
The new test_voxel_grid_safety suite has 11 cases:
- valid output parity with direct PCL, including intensity;
- the bounded issue #69 overflow class and empty rejected output;
- the largest cubic integer layout below the PCL limit;
- the first adjacent cubic layout above the limit;
- negative voxel indices;
- absolute signed-32-bit index overflow;
- zero, negative, NaN, infinite, and float-underflow leaf sizes;
- inconsistent dense/non-finite input;
- valid non-dense input with PCL dropping non-finite XYZ;
- empty, null, and all-non-finite input;
- actionable diagnostic content and stable reason code.
Every classic component call site uses the tested wrapper. The component no
longer contains a direct pcl::VoxelGrid construction.
The separate test_scanmatcher_voxel_grid_recovery suite exercises the real
ROS 2 component rather than calling the wrapper directly. Its first case sends
the bounded issue-class cloud before initialization and requires
VOXEL_GRID_LAYOUT_OVERFLOW at initial_map with no map output. The same
instance then receives a 245-point valid cloud and must publish both its map and
pose while rclcpp::ok() remains true.
The second case initializes with a valid cloud, then sends a cloud that is safe
at vg_size_for_input=0.5 but unsafe at vg_size_for_map=0.1. It requires the
asynchronous map_update stage to reject that cloud without adding a map-array
entry. Both that scan and its safe retry contain the same translated geometry;
the unsafe scan crosses a positive 0.02 m update threshold. The safe retry must
publish without further travel, proving that the rejection did not consume the
movement baseline. The case then destroys the component without one more input
callback. A separate pure-state regression binds failed versus successful
commit behavior, while the component case covers the original issue
discussion's asynchronous suspect, immediate recovery, and joining a still
joinable completed worker instead of terminating during destruction.
The positive-threshold asynchronous case at 99cce93 passed ten independent
Jazzy process executions. The prior unsafe-then-safe asynchronous case passed
ten independent processes on both supported distributions. Independent
processes are required because the
intentional five-second logging throttle retains call-site state within one
process. The repetition is a local DDS/lifecycle stability check, not a claim
about the unavailable historical bag.
Supported-distribution execution
| Environment | Exact substrate | Build | Boundary suite | Component recovery | Complete scanmatcher CTest |
|---|---|---|---|---|---|
| Humble | immutable local image ghcr.io/rsasaki0109/lidar_slam_ros2@sha256:f1a894d81b5cb7b4e2e55a7b3fc17e538722b59c07b0bec066f2ad499a5e8447; PCL 1.12.1+dfsg-3build1; GCC 11.4.0; installed lidarslam_msgs 0.9.0 and ndt_omp_ros2 0.1.0 underlay |
prior clean read-only build PASS; exact public 7b3cb99 default workflow PASS |
prior 11 / 11 PASS | exact-public test_scanmatcher_voxel_grid_recovery target PASS in 2.84 s; prior async 10 / 10 independent-process PASS |
exact-public 10 / 10 scanmatcher PASS; complete default workflow 4,241 cases / 0 errors / 0 failures / 151 skips |
| Jazzy | Ubuntu 24.04 host; PCL 1.14.0+dfsg-1; GCC 13.3.0; installed lidarslam_msgs 0.9.1 and ndt_omp_ros2 0.1.0 underlay |
local exact implementation and public 7b3cb99 default workflow PASS |
11 / 11 PASS | exact-public test_scanmatcher_voxel_grid_recovery target PASS in 2.87 s; local positive-threshold retry 10 / 10 independent-process PASS |
exact-public 10 / 10 scanmatcher PASS; complete default workflow 4,355 cases / 0 errors / 0 failures / 151 skips; current clean-checkout S1 command 3,076 / 126 skips |
The complete CTest set includes lidar undistortion, math utilities, odometry prior, pose prediction, pose acceptance, IMU processing, map-update policy, point colorization, the boundary safety suite, and the component recovery suite. Humble emitted only the existing PCL CMake policy warning. Jazzy additionally emitted the existing PCL 1.14 deprecated-Boost-header notice; neither build emitted a new-code diagnostic.
The Jazzy asynchronous case also passed a GCC ThreadSanitizer build with no
candidate-code race or lock report. TSan required address randomization to be
disabled and one library-name-only suppression for an unrelated
libOpenNI2.so static-lifecycle mutex warning; the unsuppressed run identified
only that third-party warning.
Formatting and documentation checks also passed:
ament_uncrustifyandament_cpplinton the expanded component test;- the touched legacy component/header retained the same 55 cpplint findings as
the audited base, and
CMakeLists.txtretained the same five lint findings; the follow-up adds no selected lint debt; mkdocs build --strict;git diff --check.
At exact implementation 99cce93, the complete maintained Python gate passes
2,486 tests with 13 skips and 11 existing ImageIO warnings. The historical S1
carrier reported 4,253 test cases with 0 errors, 0 failures, and 127 skips. The
2026-08-17 R1 review then reran the self-contained command from a clean
checkout, building all tested packages first, and reported 3,076 test cases
with 0 errors, 0 failures, and 126 skips.
Two byte-identical candidate-bundle rehearsals contain 261 files, total
11,931,414 bytes, and have SHA-256
5f8429e4038ca6567b2bbdb0bb00e36e5c08160631ad30ff77c7422f5080f345.
Remaining public gate
Public Draft 7b3cb99 now satisfies the public-revision and supported-CI
requirements: both default workflows execute and pass the component recovery
target. Issue #69 should still remain open until both remaining actions are
complete:
- the public issue response explains the two leaf parameters and reason codes without claiming the unavailable historical bag was exactly reproduced;
- the fix is included in a named release or the issue explicitly states the first release expected to contain it.
Until then the honest state is public Humble/Jazzy component recovery PASS, issue response and named release pending. No issue label, comment, state, image, or release was changed during this follow-up.