MoveIt PlanningScene access: live locks versus detached copies

Use LockedPlanningSceneRO when work needs read access to the monitored live scene while its read lock is held. Use copyPlanningScene() when work should continue on a detached scene after that lock is released. A PlanningSceneConstPtr obtained from the monitor does neither job by itself: const access through a pointer does not acquire a lock or turn the changing scene into a snapshot.

This distinction matters when a callback hands scene data to another function or worker. The question is which object the later work will read, and who still owns its protection. The broader issue of changing obstacles and replanning is covered in motion planning versus trajectory optimization. Here, the focus is the access and ownership contract before that planning work begins.

The implementation examined here is MoveIt 2 tag 2.15.2, commit 093360fef2ea8269c607821b551ffb2d4bc5c9a8, checked on October 6, 2026. The living Planning Scene Monitor documentation provides context; it does not establish which code is installed in your ROS environment.

Who owns the live lock?

The monitor’s getPlanningScene() overloads return handles to its internal scene. The pinned monitor header explicitly warns about direct access because the monitor updates that object. Copying the shared pointer can keep an object alive; it does not copy the scene contents or carry a read lock with it.

LockedPlanningSceneRO combines a monitor reference with a shared lock-ownership object. Its pointer-like interface makes it convenient to use, but the guard and the scene pointer have different responsibilities. If a live-scene pointer escapes a guarded section and the last guard is released, later use of that pointer is no longer protected by that read lock. Giving the escaped pointer a const type does not restore the missing protection.

The reverse mistake is to assume that one closing brace necessarily releases the lock. Copies of the guard share the same SingleUnlock owner in this version. The unlock occurs when the last shared owner is destroyed. A copied guard captured by a callback, retained in a member, or passed on to longer-lived work can therefore extend the locked interval beyond the scope you were looking at.

For a code review, follow both kinds of escape: scene aliases that outlive all guards, and guard copies that keep the lock alive unexpectedly. The wrapper’s lexical location alone does not answer either question.

Detach the scene when work must continue after unlocking

In the examined monitor implementation, copyPlanningScene() performs this sequence: acquire the read lock, clone the current scene, release the lock, then apply the caller’s optional nonempty diff to the copy. The diff application is outside that live-scene locked interval.

That ordering gives the caller a separate scene on which to continue work. It does not keep later monitor updates flowing into the detached copy. It also means the returned scene may include the caller’s diff rather than exactly the unmodified contents captured from the monitor.

The same implementation uses explicit lockSceneRead() and unlockSceneRead() calls internally, with a comment assuming that cloning will not throw. It does not internally construct the RAII guard shown below. This source reading therefore does not establish an exception-safety guarantee beyond the code being examined.

The following is an uncompiled, unexecuted C++ illustration of the two access patterns. It assumes an initialized, valid PlanningSceneMonitorPtr named monitor in a function that can return. It omits application setup and the scene-dependent work.

{
  planning_scene_monitor::LockedPlanningSceneRO live(monitor);
  if (!live)
    return;

  // Complete live-scene reads inside this protected lifetime.
  // Do not retain a live-scene alias for unguarded later use.
}  // Unlocks here only if no copied guard survives.

auto detached = monitor->copyPlanningScene();
// Work on detached after the monitor's read lock has been released.

These are two separate acquisitions, not two views promised to represent the same instant. Choose the access pattern for the work you actually need. Avoid retaining a live read guard while calling a method that acquires the same lock internally; the example deliberately ends the first guard lifetime before calling copyPlanningScene().

The read lock coordinates access to the monitor’s in-memory scene, including the optional octree read lock in this implementation. It does not stop the physical environment or certify that all sensor updates have arrived.

Keep a parented diff separate from a detached clone

scene->diff() and PlanningScene::clone(scene) are not interchangeable spellings for an independent working snapshot. In the pinned PlanningScene implementation, a plain diff constructs a child with a parent. clone() creates such a diff and then calls decoupleParent() before returning it.

Decoupling materializes inherited scene information when needed and removes the parent relationship. The constructor also maintains a separate collision world using copy-on-write while sharing the robot model. Those details are why detached from the parent scene is a more precise description than an immutable deep copy of everything. The returned scene remains an object the caller can modify, and the source does not support a universal claim that every referenced dependency has been duplicated.

Access choice Ownership to check Consequence for later work
Monitor’s const scene pointer Alias to the internal scene; no lock acquired by the getter Constness alone supplies no synchronized snapshot
LockedPlanningSceneRO Live scene access plus shared guard ownership Reads stay within the protected lifetime; copied guards can extend it
copyPlanningScene() Clone detached under the monitor’s read lock, with optional diff applied afterward Work continues on that copy; a later refresh is a separate operation
scene->diff() A child scene retaining a parent relationship Inspect inherited dependencies rather than assuming detached ownership

One path, three stored-scene views

We ran a finite geometric illustration in Blender 5.2.2 LTS through MCP on October 6, 2026. It held the carriage and route fixed while changing which obstacle geometry each case owned. This was an experiment on our authored shapes and stored copies; it did not execute MoveIt, ROS, scene locks, threads, cloning functions or a planner.

The carriage had three box-shaped parts: a body measuring 0.20 × 0.16 × 0.12 m, a neck measuring 0.06 × 0.10 × 0.16 m, and a payload measuring 0.26 × 0.24 × 0.05 m. Their center offsets from the translating reference were (0, 0, 0.10), (0.05, 0, 0.22), and (0.08, 0, 0.325) m. The reference translated from x = −0.3 m to x = +0.3 m without rotating.

The post measured 0.06 × 0.10 × 0.36 m. Its old center was (0.03, 0.27, 0.18) m; the updated center was (0.03, 0.06, 0.18) m. We first created independent old and live objects at the old position and recorded both. Only the live post’s transform then changed. Its mesh and the entire old-copy record remained unchanged. Finally, the refreshed case received an independent copy of the updated live mesh and its actual evaluated pose, expressed in the refreshed case’s frame.

The labels v0 and v1 are our audit labels, not a claimed built-in MoveIt revision counter. Each case used the same common rigid transform: Euler XYZ angles (0.31, −0.22, 0.43) radians and translation (1.1, −0.8, 0.6) m. Separate display offsets placed the cases apart in the Blender scene. The measurements below remove those layout offsets; they are not additional robot motion.

Common XY projections of retained V0, updated live V1 and refreshed V1 boxes, with the same carriage sweep and obstacle positions.
Projection of actual audited Blender vertices after removing each case’s display offset. Filled carriage boxes show the declared continuous straight sweeps; outlines show three illustrative poses. The orange post moves toward the route in V1. XY overlap alone is not proof of a 3D intersection: the independent check also used z extents and interior points against every actual finite face.

For fixed-orientation boxes moving continuously along a straight x interval, each part’s ideal swept solid is itself a box: extend its x limits by the route endpoints and retain its y and z limits. We created those full swept boxes separately from the endpoint and midpoint poses. The three drawings alone do not establish the continuous result.

Part Retained V0: separating y projection Updated live V1: signed x × y × z overlap Refreshed V1: signed x × y × z overlap
Body 140 mm 60 × 70 × 120 mm 60 × 70 × 120 mm
Neck 170 mm 60 × 40 × 160 mm 60 × 40 × 160 mm
Payload 100 mm 60 × 100 × 50 mm 60 × 100 × 50 mm

Values are rounded from actual exported vertices. In the old case, a positive separating y projection places each complete finite swept box apart from the post. It is not a general Euclidean minimum-clearance measurement. In the two updated cases, positive XYZ interval overlaps were supported by a point strictly inside both actual native boxes: the independent check tested all six faces, including both triangles of each quad. Across the six swept intersections, the smallest interior inset was about 20 mm, comfortably above the fixed numerical margin of approximately 12 micrometres. That inset supports intersection; it is not penetration depth or a collision-force measurement.

Both endpoint poses remained separated from the post in all three cases. The unchanged endpoints therefore could not substitute for checking the full declared straight sweep. The retained copy continued to describe the old obstacle position; refreshing the copy changed the answer because it changed the owned geometry. No change in path, collision settings or solver quality was needed for this illustration.

The independent audit checked 39 closed boxes, all 312 vertices, 234 quad faces and 468 edges, complete before/after records and the actual pose operands. The largest normalized corner residual was about 0.184 micrometres, within the fixed 4 micrometre allowance. Existing 165 scenes, the original context and the base file were preserved. These checks establish this finite experiment’s geometry and retention boundary, not the behavior of MoveIt’s lock or cloning implementation.

Reproduce the declared box arithmetic

The following standard-library Python example was executed separately. It reproduces the ideal prescribed intervals and endpoint comparisons. It does not connect to Blender or MoveIt or reproduce the native mesh audit. Its part_overlap_m3 output is the interval-product volume for these ideal axis-aligned boxes; it is not an exact intersection-volume algorithm for general tilted polyhedra. Overlapping parts are not added into a union volume.

"""Declared finite-box arithmetic only; no Blender, MoveIt, ROS or locking test.
Run separately only after authorization. Display offsets/common rigid rotation
do not change these normalized intervals. Samples do not prove a path clear.
"""
from math import prod

parts = {
    'body': ((0.0, 0.0, 0.10), (0.20, 0.16, 0.12)),
    'neck': ((0.05, 0.0, 0.22), (0.06, 0.10, 0.16)),
    'payload': ((0.08, 0.0, 0.325), (0.26, 0.24, 0.05)),
}
path = (-0.3, 0.3)
obstacle_size = (0.06, 0.10, 0.36)
old = (0.03, 0.27, 0.18)
live = (0.03, 0.06, 0.18)


def box(center, size):
    return [(c-s/2, c+s/2) for c, s in zip(center, size)]


def swept(center, size):
    # Exact for this fixed orientation and continuous linear x translation.
    intervals = box(center, size)
    intervals[0] = (intervals[0][0]+path[0], intervals[0][1]+path[1])
    return intervals


def compare(a, b):
    extents = [min(x[1], y[1])-max(x[0], y[0]) for x, y in zip(a, b)]
    gaps = [(axis, -e) for axis, e in enumerate(extents) if e < 0]
    if gaps:
        return {'separating_projection_gaps_m': gaps}
    if min(extents) > 0:
        return {'overlap_extents_m': extents, 'part_overlap_m3': prod(extents)}
    return {'boundary': 'touching; no positive overlap-volume claim'}


for label, obstacle_center in [('old copy v0', old), ('live v1', live),
                               ('refreshed copy v1', tuple(live))]:
    obstacle = box(obstacle_center, obstacle_size)
    print(label)
    for name, (center, size) in parts.items():
        print(' sweep', name, compare(swept(center, size), obstacle))
        for x in path:
            moved = (center[0]+x, center[1], center[2])
            print(' endpoint', x, name, compare(box(moved, size), obstacle))
print('Per-part volumes are not summed: overlapping swept parts form a union.')
print('A projection gap is not the Euclidean minimum clearance.')

Trace ownership before handing a scene to later work

Use a live guard for reads that finish within its protected lifetime, and inspect copied guards if the lock appears to stay held longer than expected. If longer work needs its own scene, examine the installed version’s detached-copy path and keep the resulting object’s ownership explicit. A plain parented diff needs a separate dependency review. A const alias is neither a lock nor a detached snapshot.

Keep the refresh decision explicit as well. A detached copy can remain internally consistent for local work while no longer representing the monitor’s current contents. Acquiring another copy is a new capture, and applying an optional diff changes what that copy contains. The illustrative old and refreshed boxes make this ownership consequence visible; they do not prescribe a refresh frequency or establish that sensor data is complete.

For deployment, inspect the code actually installed with your ROS distribution. The examined tag is an explicit source pin, not a claim that it is the newest release or compatible with every environment. This source review and finite-box experiment do not measure lock contention, exception behavior, thread scheduling, planner success, physical contact or hardware safety. A read lock protects access to in-memory data; it cannot freeze the physical world.