[0.9.15] Sensor dormancy crashes ASensor::EndPlay after stream transfer
Setup
CARLA server/API 0.9.15, source d7b45c1, UE 4.26.2 Shipping; Ubuntu 20.04.3, Python 3.8.20, NVIDIA L20 / driver 535.216.01, off-screen Vulkan. Town12 from AdditionalMaps.
Describe the bug
A non-hero vehicle with an attached collision sensor entering dormancy can crash the server with SIGSEGV. No explicit actor destruction is needed.
Related: #7772, which was closed for formatting. This report adds an independently reproduced 0.9.15 case, a candidate ownership fix, and a post-wake subscription check.
Steps to reproduce
Use a fresh, disposable server, not a shared simulation.
- Load Town12; enable synchronous 0.05-second ticks and set both
tile_stream_distanceandactor_active_distanceto 650 m. - Spawn a hero vehicle and a nearby non-hero vehicle (
role_name=scenario); attachsensor.other.collisionto the latter and calllisten()once. - Verify both the non-hero vehicle and its sensor are active.
- Move the non-hero vehicle to the hero's position plus 800 m in X, then continue ticking.
Exact coordinates and a standalone trigger excerpt are in the expandable Scripts section.
Expected behavior
The sensor should survive sleep/wake with its original subscription, without crashing the server or requiring re-subscription.
Logs / validation summary
| Build and test | Recorded result |
|---|---|
| Original 0.9.15; dormancy | Native SIGSEGV before cleanup |
| Unmodified source rebuild; same test | Native SIGSEGV before cleanup |
| Patched rebuild; same test | Actual dormancy observed; three further ticks succeed |
| Patched rebuild; sleep/wake/collision | Original subscription and independent witness both receive the collision |
Each row is one recorded run. Both patched tests ended with client exit 0 and the native server still alive before cleanup. These were CARLA-only tests, with unchanged roles/distances and no Traffic Manager.
Additional context: cause and proposed fix
Dormant actor data takes the stream through ASensor::MoveDataStream(). The source boost::optional remains engaged, but its contained stream's shared state is moved out. ASensor::EndPlay() then unconditionally calls Stream.GetToken() on that moved-from state.
The paired fix:
- Reset the source wrapper after moving the stream out.
- Guard stream closure with
Stream.IsStreamReady(), keeping sensor-manager deregistration outside the guard.
A guard alone cannot detect the still-engaged optional. Resetting alone leaves the unconditional token access invalid.
Could maintainers review this candidate fix for the UE4/0.9.x line? I have not tested the current UE5 branch, all sensor types, or exhaustive lifecycle/leak cases; this is not a claim to fix every CARLA crash.
AI disclosure: GPT-6, operating through OpenAI Codex, identified the root cause described here and developed the candidate fix. The fix was validated in actual CARLA runs as summarized above; this does not claim the first report of the crash or a fix for all CARLA failures.
Scripts
Standalone dormancy-trigger excerpt using only the CARLA Python APIThis is a shortened extraction of the recorded diagnostic's trigger, for review and reproduction. It has been syntax-checked, but this shortened file has not itself been rerun against CARLA; the validation table above comes from the original supervised diagnostic. It does not implement the separate collision-after-wake check.
Start a fresh server separately, save this as reproduce_sensor_dormancy.py, and run python reproduce_sensor_dormancy.py --port 2000. Use a matching 0.9.15 Python API. Stop the disposable server after the test; teardown is deliberately not mixed into the dormancy test.
import argparse
import carla
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=2000)
args = parser.parse_args()
client = carla.Client("127.0.0.1", args.port)
client.set_timeout(45.0)
print("client/server:", client.get_client_version(),
client.get_server_version(), flush=True)
world = client.load_world("Town12", reset_settings=False)
settings = world.get_settings()
settings.synchronous_mode = True
settings.fixed_delta_seconds = 0.05
settings.tile_stream_distance = 650.0
settings.actor_active_distance = 650.0
world.apply_settings(settings)
library = world.get_blueprint_library()
vehicle_bp = library.find("vehicle.lincoln.mkz_2020")
vehicle_bp.set_attribute("role_name", "hero")
hero_transform = world.get_map().get_waypoint(
carla.Location(x=-1708.2, y=4889.6, z=376.7)).transform
hero_transform.location.z += 0.3
hero = world.spawn_actor(vehicle_bp, hero_transform)
world.tick(45.0)
near_transform = world.get_map().get_waypoint(
carla.Location(x=-1662.2, y=4908.0, z=377.0)).transform
near_transform.location.z += 0.3
vehicle_bp.set_attribute("role_name", "scenario")
npc = world.spawn_actor(vehicle_bp, near_transform)
sensor = world.spawn_actor(library.find("sensor.other.collision"),
carla.Transform(), attach_to=npc)
events = []
sensor.listen(events.append) # Exactly one subscription, before sleep.
frame = world.tick(45.0)
if not (npc.is_active and sensor.is_active):
raise RuntimeError("Fixture invalid: both actors must start active")
print("initially active:", frame, flush=True)
far = hero.get_transform()
far.location.x += 800.0
npc.set_transform(far)
for _ in range(8):
frame = world.tick(45.0) # Observe native server status if RPC fails.
print(frame, npc.is_dormant, sensor.is_dormant, flush=True)
if npc.is_dormant and sensor.is_dormant:
break
else:
raise RuntimeError("Dormancy not observed; this is not a passing test")
for _ in range(3):
world.tick(45.0)
if not (npc.is_dormant and sensor.is_dormant):
raise RuntimeError("Dormancy did not remain stable")
print("Dormancy transition survived; stop the disposable server.",
flush=True)
if __name__ == "__main__":
main()A client RPC timeout alone does not establish this bug. Check the native server's exit status/logs; distinguish SIGSEGV from startup failure, a port conflict, and intentional cleanup.
Detailed test recordsThe following are selected fields from recorded test results, not a synthetic native backtrace. The process supervisor recorded native exit status before sending cleanup signals.
| Server | Test | Recorded result |
|---|---|---|
| Original 0.9.15 executable | Exact dormancy trigger | Native return code -11 (SIGSEGV), before cleanup |
| Independently rebuilt, unmodified 0.9.15 | Same trigger | Native return code -11, before cleanup |
| Rebuilt with the two changes below | Same trigger | Vehicle and sensor dormant at frame 17; three further dormant ticks succeed; client exits 0; native process still alive before cleanup |
| Same patched executable, fresh server | Sleep, wake, then collision | Dormant at frame 17; active again at frame 21; original callback and an independent collision witness both receive the collision at frame 70; client exits 0; native process still alive before cleanup |
For both unpatched runs, the last completed tick was frame 16, with the vehicle and sensor active. Both runs recorded the same 0.9.15 client/server versions, world settings, roles, and 800 m trigger before the failure.
For the wake test:
target_listen_calls: 1
dormancy_observed_frame: 17
wake_observed_frame: 21
collision_confirmed_by_independent_sensor: true
retained_callback_received_collision: true
target_collision_frames: [70]
witness_collision_frames: [70]
child_exit_before_cleanup: 0
native_process_alive_before_cleanup: trueFrame numbers describe these recorded runs, not a guarantee of identical frame numbering on every installation. Each row represents one recorded run, not a statistical reliability estimate. All four runs used the same diagnostic implementation; baseline and candidate used the same cooked assets, API, and rendering setup.
The wake check returns the dormant vehicle to its original nearby transform and waits for both original actor IDs to become active. After 30 settling ticks, a separate vehicle approaches at 7 m/s. A collision sensor on that vehicle independently confirms contact. Success requires the original, continuously subscribed sensor to report the same contact. A test that merely survives without entering dormancy, or never makes contact, is not counted as a pass.
Source-level ownership analysis with pinned referencesThe relevant 0.9.15 source sequence is:
FActorSensorData::RecordActorDatastoresSensor->MoveDataStream()before the Unreal actor is destroyed for dormancy. Restoration later moves this stored stream back into the sensor.ASensor::MoveDataStreamreturnsstd::move(Stream)without resetting the source wrapper.FDataStreamTmplcontains aboost::optional<StreamType>. The move leaves the source optional engaged. However, the containedstreaming::detail::Streamuses a movedshared_ptr, so the source no longer has the backing state.ASensor::EndPlayunconditionally callsStream.GetToken(). The optional-engagement check does not detect the empty backing state;Stream::token()dereferences it.
This is an ownership problem in that lifecycle path. The proposed repair restores the invariant that a sensor which exported its stream no longer presents that stream as ready for teardown.
Candidate patch
Both changes are needed:
- Reset the source wrapper after moving the stream out. A readiness guard by itself is insufficient because the moved-from optional remains engaged.
- Close the stream only when the sensor still retains it. Resetting alone leaves the unconditional
GetToken()invalid. Dormant actor data must retain its stream for wake-up.
Keep sensor-manager deregistration outside the conditional; do not skip the rest of EndPlay() with an early return.
diff --git a/Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/Sensor.h b/Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/Sensor.h
--- a/Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/Sensor.h
+++ b/Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/Sensor.h
@@ -49,6 +49,10 @@
FDataStream MoveDataStream()
{
- return std::move(Stream);
+ FDataStream OutStream = std::move(Stream);
+ // A moved-from optional remains engaged. This sensor no longer owns
+ // the stream retained by dormant actor data.
+ Stream = FDataStream{};
+ return OutStream;
}
diff --git a/Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/Sensor.cpp b/Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/Sensor.cpp
--- a/Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/Sensor.cpp
+++ b/Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/Sensor.cpp
@@ -114,10 +114,14 @@ void ASensor::EndPlay(EEndPlayReason::Type EndPlayReason)
{
Super::EndPlay(EndPlayReason);
- // close all sessions associated to the sensor stream
- auto *GameInstance = UCarlaStatics::GetGameInstance(GetEpisode().GetWorld());
- auto &StreamingServer = GameInstance->GetServer().GetStreamingServer();
- auto StreamId = carla::streaming::detail::token_type(Stream.GetToken()).get_stream_id();
- StreamingServer.CloseStream(StreamId);
+ // Dormant actor data may have taken ownership of this stream.
+ if (Stream.IsStreamReady())
+ {
+ // Close sessions only for a stream still owned by this sensor.
+ auto *GameInstance = UCarlaStatics::GetGameInstance(GetEpisode().GetWorld());
+ auto &StreamingServer = GameInstance->GetServer().GetStreamingServer();
+ auto StreamId = carla::streaming::detail::token_type(Stream.GetToken()).get_stream_id();
+ StreamingServer.CloseStream(StreamId);
+ }
UCarlaEpisode* Episode = UCarlaStatics::GetCurrentEpisode(GetWorld());The candidate was compiled and tested in a separate installation. I did not avoid the tested dormancy transition by changing the non-hero vehicle to hero, enlarging the active distance, or reconnecting the target sensor after wake.
Source: carla-simulator/carla