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
:ref:`chapter_13_asil_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:
----------------------------------------
.. list-table::
:header-rows: 1
* - 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.
.. literalinclude:: heap_check/heap_check.h
:language: cpp
:lines: 25-46
:caption: heap_check/heap_check.h — phase flag and control functions
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:
.. literalinclude:: heap_check/heap_check.h
:language: cpp
:lines: 48-61
:caption: heap_check/heap_check.h — operator new replacement
.. 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`` and ``AsProxy``
to produce the concrete skeleton and proxy types, the same pattern that generated code would use
in a production system:
.. literalinclude:: common/tire_pressure_service.h
:language: cpp
:lines: 25-59
:caption: common/tire_pressure_service.h
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()`` | Allocates (init only) |
| (init phase) | |
+----------------------------------------------------+----------------------------+
| ``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()`` | **Heap-free** |
| (operational phase) | |
+----------------------------------------------------+----------------------------+
| ``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`` | **Heap-free** |
| ``.GetSubscriptionState()`` | |
+----------------------------------------------------+----------------------------+
| ``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`` 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()``:
.. literalinclude:: event_send_receive/provider.cpp
:language: cpp
:lines: 39-63
:caption: event_send_receive/provider.cpp — init phase
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``, 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:
.. literalinclude:: event_send_receive/provider.cpp
:language: cpp
:lines: 69-101
:caption: event_send_receive/provider.cpp — operational phase and cleanup
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()``:
.. literalinclude:: event_send_receive/consumer.cpp
:language: cpp
:lines: 49-84
:caption: event_send_receive/consumer.cpp — init phase (discovery, create, subscribe)
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``, a SHM handle that gives
zero-copy access to the data the provider placed there:
.. literalinclude:: event_send_receive/consumer.cpp
:language: cpp
:lines: 82-109
:caption: event_send_receive/consumer.cpp — operational phase and cleanup
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``) 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``.
.. literalinclude:: event_send_receive/consumer_receive_handler.cpp
:language: cpp
:lines: 82-117
:caption: event_send_receive/consumer_receive_handler.cpp — handler registration and receive loop
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``, 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:
.. literalinclude:: event_field_update/provider.cpp
:language: cpp
:lines: 69-109
:caption: event_field_update/provider.cpp — operational phase with event and field update
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
:doc:`chapter 3 <../chapter_3/README>`.
The allocation-relevant rules for this chapter are:
- ``StartFindService()`` itself allocates (a ``make_shared`` 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:
.. literalinclude:: start_find_service/consumer.cpp
:language: cpp
:lines: 51-98
:caption: start_find_service/consumer.cpp — StartFindService with bounded wait before forbid_heap()
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:
.. literalinclude:: bidirectional_discovery/application_a.cpp
:language: cpp
:lines: 59-141
:caption: bidirectional_discovery/application_a.cpp — OfferService before StartFindService, then wait
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:
.. literalinclude:: bidirectional_discovery/application_a.cpp
:language: cpp
:lines: 143-187
:caption: bidirectional_discovery/application_a.cpp — operational loop: send and receive without heap
.. _chapter_13_asil_guidance:
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:
.. code-block:: bash
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:
.. code-block:: bash
cd /tmp/tutorial/chapter_13/opt/HeapFreeEventSendReceiveProvider
bin/event_send_receive_provider
And the consumer in the second terminal:
.. code-block:: bash
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//etc/mw_com_config.json``, so run each
binary from its ``opt/`` 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.
.. list-table::
:header-rows: 1
* - 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:
.. code-block:: bash
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`` 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.