PLC runtime — architecture¶
Detailed-design notes for the soft-real-time PLC heart family (PLC runtime heart on iceoryx2 (FEAT_0010)). This chapter currently covers the bounded-time dispatch sub-feature (Bounded-time dispatch (FEAT_0017)) and its zero-allocation guarantee (No heap allocation in dispatch (REQ_0060)), the scan-cycle observability sub-feature (Scan-cycle observability (FEAT_0021)), the PREEMPT_RT validation harness (PREEMPT_RT validation harness (FEAT_0022)) together with the cycle-overrun fault primitive (Cycle-overrun fault primitive (FEAT_0018)) and framework internal-fault model (Framework internal-fault model (FEAT_0024)), and the absolute-grid cyclic dispatch of Cyclic scan execution (FEAT_0011); other sub-features are added as their designs land.
Per the arc42 conventions used across this spec, design decisions are
captured as arch-decision directives, structural elements as
building-block directives, and concrete code mappings as impl
directives. Test cases live in PLC runtime — verification.
This chapter is split across pages (see the toctree): the Solution strategy framing lives here on the index; the building-block decomposition (the pre-allocated dispatch scratch and the foundation executor surfaces) lives in Building blocks; the concrete zero-allocation refactor lives in Implementation; the scan-cycle observability design lives in Scan-cycle observability; the PREEMPT_RT validation harness, the cycle-overrun fault primitive, and the framework internal-fault model live in PREEMPT_RT validation harness; and the absolute-grid cyclic dispatch design lives in Absolute-grid cyclic dispatch.
Solution strategy¶
The dispatch hot path’s zero-allocation goal is solved by moving every
per-iteration allocation up to ``Executor::build`` time and reusing
that capacity. Two design choices follow from that posture: how to
reuse the per-iteration error slot, and how to replace the unbounded
crossbeam re-dispatch channel that Graph::run_once allocates today.
The per-scan-cycle dispatch flow the rest of this chapter refines is: the
WaitSet thread blocks until a trigger fires (a grid timerfd tick, an
iceoryx2 event, or a stop notification), the dispatch loop brackets the
task-logic call with the ExecutionMonitor reference point
(pre_execute = task-logic start), runs the item (or its fault handler),
and folds the post-execute timing into the task’s cycle statistics before
re-entering the wait.
flowchart TD
Wait["WaitSet blocks on triggers<br/>(grid timerfd tick · iceoryx2 event · stop notify)"]
Wake{"wake cause?"}
EINTR["bare EINTR — dispatch nothing,<br/>count nothing, re-enter wait"]
Stop["stop request / SIGINT-SIGTERM<br/>→ return Ok(())"]
Faulted{"task faulted?"}
Skip["faulted, no handler:<br/>no submission,<br/>pre_execute NOT called"]
Pre["pre_execute reference point<br/>(task-logic start, telemetry clock)"]
PreH["pre_execute reference point<br/>(task-logic start, telemetry clock)"]
Exec["execute item logic"]
Handler["dispatch fault handler<br/>once per cycle"]
Post["post_execute: fold took / jitter /<br/>lateness into CycleStats"]
PostF["post_execute:<br/>faulted = true, took = None"]
Obs["on_cycle_stats(&CycleObservation)<br/>(once per scan attempt, incl. faulted)"]
Wait --> Wake
Wake -->|"EINTR"| EINTR --> Wait
Wake -->|"stop / termination"| Stop
Wake -->|"grid tick / event"| Faulted
Faulted -->|"no"| Pre --> Exec --> Post
Faulted -->|"yes · handler registered"| PreH --> Handler --> Post
Faulted -->|"yes · no handler"| Skip --> PostF
Post --> Obs
PostF --> Obs
Obs --> Wait
Context. Today Decision. Provision all per-iteration scratch at
Alternatives considered.
Consequences. ✅ Steady-state dispatch performs zero heap allocations
(per No heap allocation in dispatch (REQ_0060)).
✅ Worst-case re-dispatch latency is bounded by ring capacity,
not allocator behaviour.
❌ Adds one |