Chapter 13: Heap-free mw::com for ASIL-B and FFI applications#

In all previous chapters we used score::mw::com without thinking about when heap allocations happen. The middleware creates internal data structures, shared-memory handles, and subscription records during startup, and that was fine.

In ASIL-B and Freedom-From-Interference (FFI) applications things are different. Once the application enters its operational loop, heap allocation can be prohibited depending on the system’s safety requirements: uncontrolled allocations can interfere with pre-allocated resources, introduce unpredictable latency, and violate the assumptions that safety-qualified software makes about its runtime behaviour. When that prohibition applies, the application must split its lifecycle into two distinct phases:

  • An init phase (before the operational loop) in which all allocating calls are made: creating the skeleton or proxy, calling OfferService(), subscribing to events, and setting receive handlers. Heap allocation is expected here.

  • An operational phase in which no heap allocation may occur. All data movement goes through the LoLa shared-memory ring buffers, which are pre-allocated during init and accessed without touching the heap.

This chapter walks through working examples that show this two-phase pattern for the most common score::mw::com use cases.

Note

The examples run at ASIL-B configuration (asil-level: "B" in every config file) but are not themselves ASIL-qualified artifacts. They are blueprints. The section ASIL-B and FFI guidance at the end of this chapter lists what you need to address before using these patterns in production ASIL-B code.

Files/artifacts used#

The bazel project for this chapter is located in score/mw/com/doc/tutorial/chapter_13 and contains the following files:


File Name

Description

BUILD

Bazel targets for all four example groups in this chapter.

common/tire_pressure_service.h

The typed service interface (TirePressureInterface) shared by all four example groups. Based on chapter 7’s TirePressureService, extended with one event.

heap_check/heap_check.h

Header-only heap checker. Replaces operator new per-thread to abort on any heap allocation after forbid_heap() is called on that thread.

event_send_receive/provider.cpp

Provider (skeleton) for the basic event send/receive example.

event_send_receive/consumer.cpp

Consumer (proxy) for the basic event send/receive example. Uses GetNewSamples polling.

event_send_receive/consumer_receive_handler.cpp

Consumer variant using SetReceiveHandler and waitWithAbort. Not heap-free end-to-end; shown for API contrast.

event_send_receive/mw_com_config.json

Shared score::mw::com configuration for the event_send_receive group.

event_send_receive/logging.json

Logging configuration for the event_send_receive group.

event_field_update/provider.cpp

Provider (skeleton) for the combined event and field update example.

event_field_update/consumer.cpp

Consumer (proxy) for the combined event and field update example.

event_field_update/mw_com_config.json

Shared score::mw::com configuration for the event_field_update group.

event_field_update/logging.json

Logging configuration for the event_field_update group.

start_find_service/provider.cpp

Provider (skeleton) for the async discovery example using StartFindService.

start_find_service/consumer.cpp

Consumer (proxy) for the async discovery example.

start_find_service/mw_com_config.json

Shared score::mw::com configuration for the start_find_service group.

start_find_service/logging.json

Logging configuration for the start_find_service group.

bidirectional_discovery/application_a.cpp

Application A for the bidirectional example. Acts as both provider (skeleton) and consumer (proxy).

bidirectional_discovery/application_b.cpp

Application B for the bidirectional example. Acts as both provider (skeleton) and consumer (proxy).

bidirectional_discovery/application_a_mw_com_config.json

score::mw::com configuration for application A.

bidirectional_discovery/application_b_mw_com_config.json

score::mw::com configuration for application B.

bidirectional_discovery/application_a_logging.json

Logging configuration for application A.

bidirectional_discovery/application_b_logging.json

Logging configuration for application B.


The two-phase model#

The boundary between the init phase and the operational phase is enforced by a header-only heap checker (heap_check/heap_check.h). It replaces the per-thread operator new with a version that calls std::abort() if called after the application has entered the operational phase. The check is per-thread: only the thread that called forbid_heap() is constrained. Library background threads, including the LoLa worker threads, are never affected.

Listing 98 heap_check/heap_check.h — phase flag and control functions#
namespace heap_check
{

// Per-thread phase flag. false = init/cleanup (heap OK), true = operational (heap forbidden).
// Only the thread that calls forbid_heap() is constrained.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables): thread_local; intentional mutable state
inline thread_local bool tl_heap_forbidden{false};

// Call once from the application thread at the init/operational boundary.
inline void forbid_heap() noexcept
{
    tl_heap_forbidden = true;
}

// Call once from the application thread before entering cleanup/shutdown.
// After this, heap allocations are permitted again on this thread.
inline void allow_heap() noexcept
{
    tl_heap_forbidden = false;
}

}  // namespace heap_check

The replacement operator new itself reads the thread-local flag and calls std::abort() on any allocation after forbid_heap() has been called on that thread:

Listing 99 heap_check/heap_check.h — operator new replacement#
// NOLINTNEXTLINE(misc-new-delete-overloads): intentional process-global override for heap checking
void* operator new(std::size_t sz)
{
    if (heap_check::tl_heap_forbidden)
    {
        std::abort();
    }
    void* ptr = std::malloc(sz);  // NOLINT(cppcoreguidelines-no-malloc): malloc is correct inside operator new
    if (ptr == nullptr)
    {
        throw std::bad_alloc{};
    }
    return ptr;
}

Note

Include heap_check.h in exactly one translation unit per binary (the translation unit that defines main()). Including it in multiple translation units would produce duplicate operator new definitions, which is an ODR violation.

Before the application enters cleanup or shutdown, it must call heap_check::allow_heap() to re-enable allocation on the calling thread. This lets Unsubscribe(), StopOfferService(), and destructors run normally.

The service interface used in the examples#

All four example groups share a single typed service interface defined in common/tire_pressure_service.h. It is based on the TirePressureService introduced in chapter 7, extended with one event (tire_pressure_update) to demonstrate heap-free event send and receive alongside heap-free field updates. The four per-wheel pressure fields from chapter 7 are retained with the WithNotifier tag, which enables Subscribe() and GetNewSamples() on the proxy side. The examples use tire_pressure_front_left as the representative field for field-update demonstrations.

The typed API uses AsSkeleton<TirePressureInterface> and AsProxy<TirePressureInterface> to produce the concrete skeleton and proxy types, the same pattern that generated code would use in a production system:

Listing 100 common/tire_pressure_service.h#
namespace score::mw::com::tutorial
{

template <typename Trait>
class TirePressureInterface : public Trait::Base
{
  public:
    using Trait::Base::Base;

    // Event: signals a new tire pressure reading (front-left wheel, in bar).
    // The skeleton calls Allocate() and Send(); the proxy calls Subscribe() and GetNewSamples().
    typename Trait::template Event<float> tire_pressure_update{*this, "tire_pressure_update"};

    // Fields: current tire pressure per wheel (in bar).
    // WithNotifier enables Subscribe() and GetNewSamples() on the proxy side.
    // The examples use tire_pressure_front_left as the representative field for
    // field-update demonstrations.
    typename Trait::template Field<float, score::mw::com::WithNotifier> tire_pressure_front_left{
        *this,
        "tire_pressure_front_left"};
    typename Trait::template Field<float, score::mw::com::WithNotifier> tire_pressure_front_right{
        *this,
        "tire_pressure_front_right"};
    typename Trait::template Field<float, score::mw::com::WithNotifier> tire_pressure_rear_left{
        *this,
        "tire_pressure_rear_left"};
    typename Trait::template Field<float, score::mw::com::WithNotifier> tire_pressure_rear_right{
        *this,
        "tire_pressure_rear_right"};
};

using TirePressureSkeleton = score::mw::com::AsSkeleton<TirePressureInterface>;
using TirePressureProxy = score::mw::com::AsProxy<TirePressureInterface>;

}  // namespace score::mw::com::tutorial

Which API calls allocate#

The table below shows which score::mw::com calls touch the heap and which do not. The table aggregates API usage across all example binaries in this chapter. All allocating calls must be completed before heap_check::forbid_heap() is called. Calls marked “heap-free” are safe to use in the operational loop.

API call

Heap behaviour

TirePressureSkeleton::Create()

Allocates

skeleton.tire_pressure_front_left.Update() (init phase)

Allocates (init only)

skeleton.OfferService()

Allocates

skeleton.tire_pressure_update.Allocate()

Heap-free

skeleton.tire_pressure_update.Send()

Heap-free (see note)

skeleton.tire_pressure_front_left.Update() (operational phase)

Heap-free

TirePressureProxy::FindService()

Allocates

TirePressureProxy::StartFindService()

Allocates

TirePressureProxy::StopFindService()

May allocate

TirePressureProxy::Create()

Allocates

proxy.tire_pressure_update.Subscribe()

Allocates

proxy.tire_pressure_update.SetReceiveHandler()

Allocates

proxy.tire_pressure_update.GetNewSamples()

Heap-free

proxy.tire_pressure_update .GetSubscriptionState()

Heap-free

Notification::waitForWithAbort()

Heap-free

Notification::waitWithAbort()

Heap-free

Notification::notify() / reset()

Heap-free

stop_source::get_token()

Heap-free

Note

skeleton.tire_pressure_update.Send() is heap-free in normal operation. When tracing is enabled, the runtime allocates a temporary std::string inside TracingRuntime::ConvertToTracingServiceInstanceElement() for an unordered_map lookup. This is a known limitation tracked in an internal ticket. If tracing must be active during the heap-free operational phase, treat Send() as allocating.

Note

SetReceiveHandler cannot be used in a fully heap-free provider-consumer pair today. On the consumer side, calling SetReceiveHandler creates a std::make_shared<ScopedEventReceiveHandler> once in the init phase, which is correctly placed before forbid_heap(). The problem is on the provider side: when the consumer sends its registration message at runtime, the LoLa messaging layer inserts the consumer’s node identifier into an STL container that uses a default allocator (message_passing_service_instance.cpp, marked // TODO: PMR). If the provider has already called forbid_heap(), that insertion triggers the abort. The heap-free examples therefore use GetNewSamples() polling instead. See the async receive handler section below for the full API demonstration.

Provider application: sending events without heap allocation#

The event_send_receive/provider.cpp example shows the complete provider lifecycle. During the init phase, the skeleton is created, the field is given an initial value with Update() (required by the framework before OfferService() is called), and OfferService() is called. All of these calls allocate heap memory and finish before forbid_heap():

Listing 101 event_send_receive/provider.cpp — init phase#
    // ---- INIT PHASE ----

    score::Result<score::mw::com::InstanceSpecifier> specifier_result =
        score::mw::com::InstanceSpecifier::Create(std::string{"/tutorial/event_send_receive/TirePressureService"});
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(specifier_result.has_value(), "Failed to create InstanceSpecifier!");

    score::Result<TirePressureSkeleton> skeleton_result = TirePressureSkeleton::Create(specifier_result.value());
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(skeleton_result.has_value(), "Failed to create TirePressureSkeleton!");
    TirePressureSkeleton& skeleton = skeleton_result.value();

    score::Result<void> init_update_result = skeleton.tire_pressure_front_left.Update(0.0F);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(init_update_result.has_value(),
                                                "Failed to set tire_pressure_front_left!");

    score::Result<void> init_fr_result = skeleton.tire_pressure_front_right.Update(0.0F);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(init_fr_result.has_value(), "Failed to set tire_pressure_front_right!");

    score::Result<void> init_rl_result = skeleton.tire_pressure_rear_left.Update(0.0F);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(init_rl_result.has_value(), "Failed to set tire_pressure_rear_left!");

    score::Result<void> init_rr_result = skeleton.tire_pressure_rear_right.Update(0.0F);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(init_rr_result.has_value(), "Failed to set tire_pressure_rear_right!");

    score::Result<void> offer_result = skeleton.OfferService();
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(offer_result.has_value(), "Failed to offer TirePressureSkeleton!");

Once heap_check::forbid_heap() is called, the application enters the operational phase. The provider loop calls skeleton.tire_pressure_update.Allocate() on every iteration. This call does not touch the heap: it acquires a slot from the LoLa shared-memory ring buffer and returns a SampleAllocateePtr<float>, an RAII handle to that slot. The provider fills the value in place and calls Send(std::move(sample)) to publish it. Both calls stay on the SHM ring buffer; no operator new is involved:

Listing 102 event_send_receive/provider.cpp — operational phase and cleanup#
    for (std::uint32_t i = 0U; i < kNumIterations; ++i)
    {
        score::Result<score::mw::com::SampleAllocateePtr<float>> sample_result =
            skeleton.tire_pressure_update.Allocate();
        if (!sample_result.has_value())
        {
            score::mw::log::LogError("SkEs")
                << "tire_pressure_update.Allocate failed — all SHM slots may be held by slow subscribers";
            heap_check::allow_heap();
            return EXIT_FAILURE;
        }

        *sample_result.value() = static_cast<float>(i) * 0.1F;

        score::Result<void> send_result = skeleton.tire_pressure_update.Send(std::move(sample_result.value()));
        if (!send_result.has_value())
        {
            score::mw::log::LogError("SkEs") << "tire_pressure_update.Send failed";
            heap_check::allow_heap();
            return EXIT_FAILURE;
        }

        std::this_thread::sleep_for(kCycleTime);
    }

    // ---- CLEANUP ----
    heap_check::allow_heap();
    skeleton.StopOfferService();

    score::mw::log::LogInfo("SkEs") << "Operational phase complete (" << kNumIterations << " iterations)";
    score::mw::log::LogInfo("SkEs") << "Completed successfully";
    return 0;
}

Note the heap_check::allow_heap() call on each early-return error path inside the operational loop, and again unconditionally before the cleanup calls at the end. This re-enables allocation on the calling thread so that StopOfferService() and the skeleton destructor can run normally.

Consumer application: receiving events without heap allocation#

The event_send_receive/consumer.cpp example shows the corresponding consumer. The init phase polls FindService() until the provider’s service appears, creates the proxy, and subscribes to the event. All of these calls allocate and finish before forbid_heap():

Listing 103 event_send_receive/consumer.cpp — init phase (discovery, create, subscribe)#
    score::mw::com::ServiceHandleContainer<TirePressureProxy::HandleType> handles{};
    for (std::uint32_t attempt = 0U; attempt < kMaxFindAttempts; ++attempt)
    {
        score::Result<score::mw::com::ServiceHandleContainer<TirePressureProxy::HandleType>> handles_result =
            TirePressureProxy::FindService(specifier_result.value());
        if (!handles_result.has_value())
        {
            score::mw::log::LogError("PxEs") << "FindService failed — check mw_com_config.json";
            return EXIT_FAILURE;
        }
        handles = std::move(handles_result.value());
        if (!handles.empty())
        {
            break;
        }
        std::this_thread::sleep_for(kFindRetryDelay);
    }
    if (handles.empty())
    {
        score::mw::log::LogError("PxEs")
            << "Service not found after all attempts — is event_send_receive/provider running?";
        return EXIT_FAILURE;
    }

    score::Result<TirePressureProxy> proxy_result = TirePressureProxy::Create(handles.front());
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(proxy_result.has_value(), "Failed to create TirePressureProxy!");
    TirePressureProxy& proxy = proxy_result.value();

    score::Result<void> subscribe_result = proxy.tire_pressure_update.Subscribe(kMaxSamples);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(subscribe_result.has_value(),
                                                "Failed to subscribe to tire_pressure_update!");

    // ---- OPERATIONAL PHASE (see README.rst for heap behavior of each API) ----
    score::mw::log::LogInfo("PxEs") << "Entering operational phase (heap forbidden)";
    heap_check::forbid_heap();

After forbid_heap(), the operational loop calls proxy.tire_pressure_update.GetNewSamples(), which reads directly from the LoLa SHM ring buffer. The callback lambda receives a SamplePtr<float>, a SHM handle that gives zero-copy access to the data the provider placed there:

Listing 104 event_send_receive/consumer.cpp — operational phase and cleanup#
    score::mw::log::LogInfo("PxEs") << "Entering operational phase (heap forbidden)";
    heap_check::forbid_heap();

    for (std::uint32_t i = 0U; i < kNumIterations; ++i)
    {
        // GetNewSamples reads from the LoLa shared-memory ring buffer — no heap allocation.
        score::Result<std::size_t> reading_result = proxy.tire_pressure_update.GetNewSamples(
            [](score::mw::com::SamplePtr<float> sample) noexcept {
                static_cast<void>(*sample);
            },
            kMaxSamples);
        if (!reading_result.has_value())
        {
            score::mw::log::LogError("PxEs") << "tire_pressure_update.GetNewSamples failed";
            heap_check::allow_heap();
            return EXIT_FAILURE;
        }

        std::this_thread::sleep_for(kCycleTime);
    }

    // ---- CLEANUP ----
    heap_check::allow_heap();
    score::mw::log::LogInfo("PxEs") << "Operational phase complete (" << kNumIterations << " iterations)";

    proxy.tire_pressure_update.Unsubscribe();

    score::mw::log::LogInfo("PxEs") << "Completed successfully";

Async receive handler (not heap-free)#

The event_send_receive/consumer_receive_handler.cpp binary shows the async receive API: SetReceiveHandler registers a callback that fires on the middleware thread whenever a new sample arrives, and waitWithAbort blocks the application thread until the callback signals it via a Notification.

This binary is not heap-free end-to-end. SetReceiveHandler allocates on the proxy side (make_shared<ScopedEventReceiveHandler>) and also causes the skeleton background thread to allocate when it registers the notification. The binary therefore never calls forbid_heap(). It is included here to make the full async API available as a reference. For a heap-free consumer, use GetNewSamples() polling as shown in event_send_receive/consumer.cpp.

Listing 105 event_send_receive/consumer_receive_handler.cpp — handler registration and receive loop#
    score::concurrency::Notification event_received{};
    score::cpp::stop_source stop_source{};

    // SetReceiveHandler allocates on both the proxy side (make_shared<ScopedEventReceiveHandler>)
    // and on the skeleton background thread when it registers the notification. This binary
    // therefore does not call forbid_heap().
    score::Result<void> handler_result = proxy.tire_pressure_update.SetReceiveHandler([&event_received]() noexcept {
        event_received.notify();
    });
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(handler_result.has_value(),
                                                "Failed to register tire_pressure_update receive handler!");

    score::Result<void> subscribe_result = proxy.tire_pressure_update.Subscribe(kMaxSamples);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(subscribe_result.has_value(),
                                                "Failed to subscribe to tire_pressure_update!");

    score::mw::log::LogInfo("PxEsH") << "Entering receive loop";

    for (std::uint32_t i = 0U; i < kNumIterations; ++i)
    {
        event_received.waitWithAbort(stop_source.get_token());
        event_received.reset();

        score::Result<std::size_t> reading_result = proxy.tire_pressure_update.GetNewSamples(
            [](score::mw::com::SamplePtr<float> sample) noexcept {
                static_cast<void>(*sample);
            },
            kMaxSamples);
        if (!reading_result.has_value())
        {
            score::mw::log::LogError("PxEsH") << "tire_pressure_update.GetNewSamples failed";
            return EXIT_FAILURE;
        }
    }

    proxy.tire_pressure_update.Unsubscribe();

Fields in the operational phase#

A SkeletonField requires a call to Update() with an initial value before OfferService() is called. The framework’s PrepareOffer() checks for this initial value and returns an error if it is absent. This init-phase Update() internally stores the value via std::make_unique<FieldType>, which is heap allocation, correctly placed before forbid_heap().

After OfferService() the picture changes: Update() routes through the event channel directly to the LoLa shared-memory binding. From that point on, calling Update() in the operational loop does not allocate. The event_field_update/provider.cpp example shows both an event send and a field update in the same heap-free loop:

Listing 106 event_field_update/provider.cpp — operational phase with event and field update#
    for (std::uint32_t i = 0U; i < kNumIterations; ++i)
    {
        score::Result<score::mw::com::SampleAllocateePtr<float>> sample_result =
            skeleton.tire_pressure_update.Allocate();
        if (!sample_result.has_value())
        {
            score::mw::log::LogError("SkEf")
                << "tire_pressure_update.Allocate failed — all SHM slots may be held by slow subscribers";
            heap_check::allow_heap();
            return EXIT_FAILURE;
        }

        *sample_result.value() = static_cast<float>(i) * 0.1F;

        score::Result<void> send_result = skeleton.tire_pressure_update.Send(std::move(sample_result.value()));
        if (!send_result.has_value())
        {
            score::mw::log::LogError("SkEf") << "tire_pressure_update.Send failed";
            heap_check::allow_heap();
            return EXIT_FAILURE;
        }

        score::Result<void> field_result = skeleton.tire_pressure_front_left.Update(static_cast<float>(i) * 0.1F);
        if (!field_result.has_value())
        {
            score::mw::log::LogError("SkEf") << "tire_pressure_front_left.Update failed";
            heap_check::allow_heap();
            return EXIT_FAILURE;
        }

        std::this_thread::sleep_for(kCycleTime);
    }

    // ---- CLEANUP ----
    heap_check::allow_heap();
    skeleton.StopOfferService();

    score::mw::log::LogInfo("SkEf") << "Operational phase complete (" << kNumIterations << " iterations)";
    score::mw::log::LogInfo("SkEf") << "Completed successfully";
    return 0;
}

Async service discovery with StartFindService#

The start_find_service example shows how StartFindService() fits into the heap-free model. For the general API, the deadlock-avoidance rationale, and StopFindService(), see chapter 3.

The allocation-relevant rules for this chapter are:

  • StartFindService() itself allocates (a make_shared<FindServiceHandler> internally) and must be called in the init phase.

  • Proxy::Create() is called inside the discovery callback, which runs on a middleware worker thread. The per-thread heap checker does not constrain worker threads, so Create() may allocate freely there.

  • The main thread must wait for the callback to complete before calling forbid_heap(), because Create() allocates heap memory that must be in place before the operational phase begins.

The start_find_service/consumer.cpp example shows this:

Listing 107 start_find_service/consumer.cpp — StartFindService with bounded wait before forbid_heap()#
    std::optional<TirePressureProxy> proxy_opt{};
    score::concurrency::Notification proxy_ready{};
    score::cpp::stop_source stop_source{};

    score::Result<score::mw::com::FindServiceHandle> find_service_handle_result = TirePressureProxy::StartFindService(
        [&proxy_opt, &proxy_ready](score::mw::com::ServiceHandleContainer<TirePressureProxy::HandleType> handles,
                                   score::mw::com::FindServiceHandle /*fsh*/) {
            if (handles.empty())
            {
                // Service disappeared — ignore; this example uses one-shot discovery.
                return;
            }
            score::Result<TirePressureProxy> proxy_result = TirePressureProxy::Create(handles.front());
            if (!proxy_result.has_value())
            {
                score::mw::log::LogError("PxSF") << "TirePressureProxy::Create failed in discovery callback";
                proxy_ready.notify();  // unblock main so it can check proxy_opt and exit
                return;
            }
            proxy_opt.emplace(std::move(proxy_result.value()));
            proxy_ready.notify();
        },
        specifier_result.value());
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(find_service_handle_result.has_value(),
                                                "Failed to start service discovery!");

    // Wait for callback to complete Proxy::Create() before forbid_heap().
    bool notified = proxy_ready.waitForWithAbort(kDiscoveryTimeout, stop_source.get_token());

    // Stop continuous discovery — one-shot pattern, we only need to find once.
    score::Result<void> stop_result = TirePressureProxy::StopFindService(find_service_handle_result.value());
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(stop_result.has_value(), "Failed to stop service discovery!");

    if (!notified || !proxy_opt.has_value())
    {
        score::mw::log::LogError("PxSF")
            << "Service not found within timeout — is start_find_service/provider running?";
        return EXIT_FAILURE;
    }

    TirePressureProxy& proxy = proxy_opt.value();

    score::Result<void> subscribe_result = proxy.tire_pressure_update.Subscribe(kMaxSamples);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(subscribe_result.has_value(),
                                                "Failed to subscribe to tire_pressure_update!");

    // ---- OPERATIONAL PHASE (see README.rst for heap behavior of each API) ----
    score::mw::log::LogInfo("PxSF") << "Entering operational phase (heap forbidden)";

The key rule is: wait for the discovery callback to complete before calling forbid_heap(). Here proxy_ready.waitForWithAbort(kDiscoveryTimeout, ...) provides both the wait and the timeout. Only after that call returns with a valid proxy does the example call forbid_heap() and enter the operational loop.

Bidirectional discovery#

The bidirectional_discovery example takes this one step further: both applications act as a provider and a consumer at the same time. Each must offer its own service and create a proxy against the other’s service, all before entering the heap-free operational phase.

Two constraints together determine the required init-phase ordering:

  • OfferService() registers the service in the service-discovery registry. A side that has not called OfferService() is not visible to service discovery, so FindService() and the StartFindService() callback cannot find it and Proxy::Create() cannot proceed. OfferService() also creates the shared-memory region that the proxy attaches to.

  • Proxy::Create() allocates heap memory and must therefore complete before forbid_heap() is called.

Each application therefore calls OfferService() first, making its service visible in the service-discovery registry immediately. It then calls StartFindService() and waits with a bounded timeout for the other application’s service to appear and for Proxy::Create() to complete inside the discovery callback. Only after that wait returns with a valid proxy does the application call forbid_heap().

A structural consequence of this ordering is that neither application can deadlock: because each offers its service before waiting, the other application can always proceed with Proxy::Create() regardless of which one reaches OfferService() first.

The bidirectional_discovery/application_a.cpp example shows this sequence:

Listing 108 bidirectional_discovery/application_a.cpp — OfferService before StartFindService, then wait#
    // ---- INIT PHASE ----

    score::Result<score::mw::com::InstanceSpecifier> own_spec_result =
        score::mw::com::InstanceSpecifier::Create(std::string{"/tutorial/bidirectional_discovery/application_a"});
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(own_spec_result.has_value(), "Failed to create InstanceSpecifier!");

    score::Result<TirePressureSkeleton> skeleton_result = TirePressureSkeleton::Create(own_spec_result.value());
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(skeleton_result.has_value(), "Failed to create TirePressureSkeleton!");
    TirePressureSkeleton& skeleton = skeleton_result.value();

    score::Result<void> init_update_result = skeleton.tire_pressure_front_left.Update(0.0F);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(init_update_result.has_value(),
                                                "Failed to set tire_pressure_front_left!");

    score::Result<void> init_fr_result = skeleton.tire_pressure_front_right.Update(0.0F);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(init_fr_result.has_value(), "Failed to set tire_pressure_front_right!");

    score::Result<void> init_rl_result = skeleton.tire_pressure_rear_left.Update(0.0F);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(init_rl_result.has_value(), "Failed to set tire_pressure_rear_left!");

    score::Result<void> init_rr_result = skeleton.tire_pressure_rear_right.Update(0.0F);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(init_rr_result.has_value(), "Failed to set tire_pressure_rear_right!");

    // Offer before starting discovery. OfferService() registers this application's service
    // in the service-discovery registry and creates the shared-memory region that
    // application B needs for Proxy::Create(). Both applications can start in any order.
    score::Result<void> offer_result = skeleton.OfferService();
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(offer_result.has_value(), "Failed to offer TirePressureSkeleton!");

    score::Result<score::mw::com::InstanceSpecifier> remote_spec_result =
        score::mw::com::InstanceSpecifier::Create(std::string{"/tutorial/bidirectional_discovery/application_b"});
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(remote_spec_result.has_value(),
                                                "Failed to create remote InstanceSpecifier!");

    std::optional<TirePressureProxy> remote_proxy{};
    score::concurrency::Notification proxy_ready{};
    score::cpp::stop_source stop_source{};

    score::Result<score::mw::com::FindServiceHandle> find_service_handle_result = TirePressureProxy::StartFindService(
        [&remote_proxy, &proxy_ready](score::mw::com::ServiceHandleContainer<TirePressureProxy::HandleType> handles,
                                      score::mw::com::FindServiceHandle /*fsh*/) {
            if (handles.empty())
            {
                return;
            }
            score::Result<TirePressureProxy> proxy_result = TirePressureProxy::Create(handles.front());
            if (!proxy_result.has_value())
            {
                score::mw::log::LogError("BiA") << "TirePressureProxy::Create failed in discovery callback";
                proxy_ready.notify();
                return;
            }
            remote_proxy.emplace(std::move(proxy_result.value()));
            proxy_ready.notify();
        },
        remote_spec_result.value());
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(find_service_handle_result.has_value(),
                                                "Failed to start service discovery!");

    // Wait for application B's Proxy::Create() to complete before forbid_heap().
    // Proxy::Create() allocates heap memory and can only succeed after application B
    // has called OfferService() and registered itself in service discovery.
    bool notified = proxy_ready.waitForWithAbort(kDiscoveryTimeout, stop_source.get_token());

    score::Result<void> stop_result = TirePressureProxy::StopFindService(find_service_handle_result.value());
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(stop_result.has_value(), "Failed to stop service discovery!");

    if (!notified || !remote_proxy.has_value())
    {
        score::mw::log::LogError("BiA") << "Application B not found within timeout — is application_b running?";
        return EXIT_FAILURE;
    }

    TirePressureProxy& proxy = remote_proxy.value();

    score::Result<void> subscribe_result = proxy.tire_pressure_update.Subscribe(kMaxSamples);
    SCORE_LANGUAGE_FUTURECPP_ASSERT_PRD_MESSAGE(subscribe_result.has_value(),
                                                "Failed to subscribe to tire_pressure_update!");

    // ---- OPERATIONAL PHASE (see README.rst for heap behavior of each API) ----
    score::mw::log::LogInfo("BiA") << "Entering operational phase (heap forbidden)";
    heap_check::forbid_heap();

In the operational phase, application A simultaneously sends on its own skeleton event and polls the remote proxy’s event in the same loop, both heap-free:

Listing 109 bidirectional_discovery/application_a.cpp — operational loop: send and receive without heap#
    for (std::uint32_t i = 0U; i < kNumIterations; ++i)
    {
        score::Result<score::mw::com::SampleAllocateePtr<float>> sample_result =
            skeleton.tire_pressure_update.Allocate();
        if (!sample_result.has_value())
        {
            score::mw::log::LogError("BiA") << "tire_pressure_update.Allocate failed";
            heap_check::allow_heap();
            return EXIT_FAILURE;
        }

        *sample_result.value() = static_cast<float>(i) * 0.1F;

        score::Result<void> send_result = skeleton.tire_pressure_update.Send(std::move(sample_result.value()));
        if (!send_result.has_value())
        {
            score::mw::log::LogError("BiA") << "tire_pressure_update.Send failed";
            heap_check::allow_heap();
            return EXIT_FAILURE;
        }

        score::Result<std::size_t> recv_result = proxy.tire_pressure_update.GetNewSamples(
            [](score::mw::com::SamplePtr<float> sample) noexcept {
                static_cast<void>(*sample);
            },
            kMaxSamples);
        if (!recv_result.has_value())
        {
            score::mw::log::LogError("BiA") << "tire_pressure_update.GetNewSamples failed";
            heap_check::allow_heap();
            return EXIT_FAILURE;
        }

        std::this_thread::sleep_for(kCycleTime);
    }

    // ---- CLEANUP ----
    heap_check::allow_heap();
    score::mw::log::LogInfo("BiA") << "Operational phase complete (" << kNumIterations << " iterations)";

    proxy.tire_pressure_update.Unsubscribe();
    skeleton.StopOfferService();

    score::mw::log::LogInfo("BiA") << "Completed successfully";
    return 0;

ASIL-B and FFI guidance#

The examples are correctly structured blueprints for ASIL-B production code. Before copying these patterns into a safety-qualified context, however, there are several things you still need to take care of:

  • MISRA deviations. heap_check.h requires four project-level deviation records: a mutable thread_local variable, std::malloc/std::free inside the operator new replacement, throw std::bad_alloc, and std::abort() as the fail-fast reaction (covered by the project’s pre-existing deviation for Rule 18-5-2). Register these formally before use.

  • Safety monitor notification. The std::abort() in operator new terminates the process immediately. In a production ASIL-B system, the application should notify the safety monitor or FMEA-defined error handler before (or instead of) aborting.

  • Multi-threaded phase boundaries. The thread-local flag does not need synchronisation between threads. The examples are correct because only the main thread calls forbid_heap(). If your code has multiple application threads each managing their own phase boundary, analyse the happens-before relationship for each thread separately.

  • Cleanup paths. After allow_heap() is called, all cleanup code runs with heap permitted. If your shutdown re-enters a heap-free state, bracket each re-entry explicitly with forbid_heap()/allow_heap() pairs.

  • Watchdog supervision. The waitForWithAbort() call in the discovery phase already carries a 5-second application-level timeout. Any blocking call in an ASIL-B operational loop needs an equivalent deadline; relying on std::stop_token cancellation alone is not enough for watchdog compliance.

  • Single-TU compilation. The header-only inclusion pattern is convenient for examples but risks ODR violations if included in more than one translation unit. In production code, compile the operator new replacements into a single dedicated translation unit and treat it as a separately audited object.

How to run the examples#

The example source files live inside this chapter directory (score/mw/com/doc/tutorial/chapter_13/) and are built as part of the main workspace. Each group pairs a provider (skeleton) binary with a consumer (proxy) binary that must run concurrently. Start the provider first in one terminal and the consumer in a second terminal. Both binaries exit with code 0 on success. An std::abort() during the run indicates a heap allocation in the operational phase.

Build the tar archives and extract them into a temporary directory. The event_send_receive group is shown below as a representative example:

bazel build //score/mw/com/doc/tutorial/chapter_13:event_send_receive_provider-tar
bazel build //score/mw/com/doc/tutorial/chapter_13:event_send_receive_consumer-tar

mkdir -p /tmp/tutorial/chapter_13
tar -xf bazel-bin/score/mw/com/doc/tutorial/chapter_13/event_send_receive_provider-tar.tar -C /tmp/tutorial/chapter_13/
tar -xf bazel-bin/score/mw/com/doc/tutorial/chapter_13/event_send_receive_consumer-tar.tar -C /tmp/tutorial/chapter_13/

Then start the provider in the first terminal:

cd /tmp/tutorial/chapter_13/opt/HeapFreeEventSendReceiveProvider
bin/event_send_receive_provider

And the consumer in the second terminal:

cd /tmp/tutorial/chapter_13/opt/HeapFreeEventSendReceiveConsumer
bin/event_send_receive_consumer

Note

The location from which you start each binary matters. score::mw::com resolves its configuration at ./etc/mw_com_config.json relative to the working directory. The tar archive places the configuration at opt/<AppName>/etc/mw_com_config.json, so run each binary from its opt/<AppName> directory as shown above.

The table below lists the tar target, extracted directory, and binary name for each example in this chapter. Replace the names in the commands above to run a different group.

Example

Tar target

Extracted directory

Binary

event_send_receive provider

:event_send_receive_provider-tar

opt/HeapFreeEventSendReceiveProvider

bin/event_send_receive_provider

event_send_receive consumer

:event_send_receive_consumer-tar

opt/HeapFreeEventSendReceiveConsumer

bin/event_send_receive_consumer

event_field_update provider

:event_field_update_provider-tar

opt/HeapFreeEventFieldUpdateProvider

bin/event_field_update_provider

event_field_update consumer

:event_field_update_consumer-tar

opt/HeapFreeEventFieldUpdateConsumer

bin/event_field_update_consumer

start_find_service provider

:start_find_service_provider-tar

opt/HeapFreeStartFindServiceProvider

bin/start_find_service_provider

start_find_service consumer

:start_find_service_consumer-tar

opt/HeapFreeStartFindServiceConsumer

bin/start_find_service_consumer

bidirectional_discovery application A

:bidirectional_discovery_application_a-tar

opt/HeapFreeBidirectionalDiscoveryA

bin/bidirectional_discovery_application_a

bidirectional_discovery application B

:bidirectional_discovery_application_b-tar

opt/HeapFreeBidirectionalDiscoveryB

bin/bidirectional_discovery_application_b

The four example groups and their pairing:

  • event_send_receive: basic event send and receive. Start the provider first. The consumer_receive_handler variant demonstrates the async receive-handler API but is not heap-free.

  • event_field_update: event and field update together. Start the provider first.

  • start_find_service: async discovery via StartFindService. Start the provider first (or simultaneously; the consumer uses a 5-second discovery timeout).

  • bidirectional_discovery: both applications offer and consume simultaneously. Start in any order.

Before re-running, clear stale shared-memory objects:

rm -f /dev/shm/lola-*

Summary#

A short summary of what we have learned in this chapter:

  • ASIL-B and FFI operational code must be heap-free after initialisation. Use the two-phase model: all allocating API calls go in the init phase, before heap_check::forbid_heap(). The operational loop runs with heap forbidden and uses only shared-memory paths.

  • The heap checker (heap_check/heap_check.h) replaces operator new per-thread and aborts on any allocation after forbid_heap(). The check is per-thread, so library background threads are unaffected.

  • Provider events use Allocate() + Send(std::move(sample)) in the operational loop. Allocate() returns a SampleAllocateePtr<T> backed by the LoLa SHM ring buffer, with no heap involved.

  • Provider fields require an initial Update() call before OfferService() (allocates). After OfferService(), Update() writes directly to SHM and is heap-free.

  • Consumer events use Subscribe() in the init phase (allocates) and GetNewSamples() in the operational loop (heap-free, SHM access). SetReceiveHandler() is not heap-free end-to-end today due to a framework limitation on the skeleton side. See consumer_receive_handler.cpp for the async receive API.

  • Async service discovery with StartFindService() (see also chapter 3) must be called in the init phase. The discovery callback runs on a middleware worker thread and may allocate freely. The main thread must wait for the callback to complete (with a timeout) before calling forbid_heap().

  • Bidirectional discovery between two applications requires OfferService() before StartFindService(). This ensures both sides are visible in service discovery so each can create a proxy against the other, with all allocations completing before forbid_heap().

  • Before calling forbid_heap(): TirePressureSkeleton::Create(), OfferService(), the initial Field::Update(), FindService()/StartFindService(), Proxy::Create(), Subscribe().

  • After the operational loop: call allow_heap() unconditionally, then Unsubscribe(), StopOfferService(), and let destructors run normally.

  • Adapting to ASIL-B production requires formal MISRA deviation records for heap_check.h, safety-monitor notification before std::abort(), per-thread happens-before analysis, watchdog supervision on all blocking waits, and single-TU compilation of the operator new replacement.