#9881·carla

[0.9.15] Sensor dormancy crashes ASensor::EndPlay after stream transfer

Author: liyiersanCreated Sep 12, 2026Updated Sep 12, 2026

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.

  1. Load Town12; enable synchronous 0.05-second ticks and set both tile_stream_distance and actor_active_distance to 650 m.
  2. Spawn a hero vehicle and a nearby non-hero vehicle (role_name=scenario); attach sensor.other.collision to the latter and call listen() once.
  3. Verify both the non-hero vehicle and its sensor are active.
  4. 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 API

This 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.

python
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 records

The 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: true

Frame 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 references

The relevant 0.9.15 source sequence is:

  1. FActorSensorData::RecordActorData stores Sensor->MoveDataStream() before the Unreal actor is destroyed for dormancy. Restoration later moves this stored stream back into the sensor.
  2. ASensor::MoveDataStream returns std::move(Stream) without resetting the source wrapper.
  3. FDataStreamTmpl contains a boost::optional<StreamType>. The move leaves the source optional engaged. However, the contained streaming::detail::Stream uses a moved shared_ptr, so the source no longer has the backing state.
  4. ASensor::EndPlay unconditionally calls Stream.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.

Two-file patch against the pinned 0.9.15 source
diff
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.