RTE RBC 2oo2 Reference Application 0.1.0 build 0006685
SIL 4 2oo2 Dual-Site Fault-Tolerant ERTMS Radio Block Centre Reference Architecture
Loading...
Searching...
No Matches
RBC_GP reference design narrative

The detailed narrative of the reference RBC executable: the A/B cyclic executive, cross-compare, West/East negotiation, live failover and its honest limitations. It was written before the GP/GA/SA split and before ADR-037 split role C into per-domain gateway processes, and it is partly outdated. Where this file and ../CLAUDE.md disagree on the current process and binary shape, CLAUDE.md and the contents of dist/<platform>/bin/ win. The current binaries are rte_example (roles A/B) and rte_gateway_c_{train,il,ctc,status} (role C, one c-<site> container runs all four).

The negotiation, failover, cross-compare and A/B cycle descriptions below are historical** (they name files such as channel_ab_negotiate.c, a negotiation-link watchdog and an M24/M15 decision that no longer exist, and they predate the site-role service of Platform_RTE ADR-040). The current, end-to-end description is Site redundancy and A/B agreement; where the two differ, that page and the source win. Main differences: the site role is decided by Platform_RTE rte_site_role (rules 1-7 plus a readiness gate); a silent counterpart never promotes a site (a dispatcher token is needed); A and B agree their startup timestamp over the A/B peer link; the A/B role check is rte_site_role's REQ-SITEROLE-014 check; cross-compare compares the train-session table on its own ab-crosscompare link; both A and B stage outputs.

Running the full stack (Docker Compose, Robot Framework, simulators) is described by RBC_Test_Env (test/RBC_Test_Env/doc/BUILD_GUIDE.dox), not here.

A real Linux/macOS application built on Platform_RTE (Platform_RTE), demonstrating a multi-process, dual-site (West/East) A/B/C redundancy architecture running on a real POSIX OAL OSAdapter — now provided by the sibling Platform_OS_POSIX project, not owned by this repo. This is a separate project from Platform_RTE itself: it consumes Platform_RTE as a dependency, the way a real downstream integrator would, per ADR-005's philosophy that an OSAdapter is an integrator-supplied implementation, not part of the reusable safety framework.

Architecture

flowchart TB
    subgraph WEST["WEST site"]
        AW["A"] <-->|cross-compare| BW["B"]
        AW <-->|"M136 down, M24/15 up"| CW["C (ACTIVE)"]
        BW <-->|"M136 down, M24/15 up"| CW
    end
    subgraph EAST["EAST site"]
        AE["A"] <-->|cross-compare| BE["B"]
        AE <-->|"M136 down, M24/15 up"| CE["C (SUPPRESSED)"]
        BE <-->|"M136 down, M24/15 up"| CE
    end
    AW <-->|"negotiate (this channel's own link)"| AE
    BW <-->|"negotiate (this channel's own, SEPARATE link)"| BE

Six containers, one per (role, site) pair - no standalone SITE role. West/East negotiation is folded directly into A and B themselves: each of A and B owns its own independent negotiation link to its counterpart on the other site (AB/com/channel_ab_negotiate.c), so site-level negotiation is never a single point of failure the way one shared process would be. A and B cross-check their two independently-negotiated outcomes over their existing same-site peer link - see "West/East negotiation" below.

This project simulates a small slice of an RBC (Radio Block Centre) - A and B are the RBC itself**, a 2oo2 redundant pair. Neither the Interlocking (IL) nor the train talks to the RBC directly in this simulation; C plays that role - the interface to "the rest of the ERTMS system" - originating the train's own Position Report and receiving back the movement authority the RBC hands to IL/the train. Concretely: C periodically generates a simulated M136 (Position Report, D_LRBG = x % RTE_EXAMPLE_DLRBG_MODULUS) and forwards it to A and B; A and B each independently decide M24 (movement authority - D_LRBG < RTE_EXAMPLE_DLRBG_THRESHOLD) or M15 (the more restrictive SR Authorisation - D_LRBG >= threshold), cross-compare that decision (via the framework's rte_cross_comparator, ADR-025 - see "Framework feature adoption" below), and only A forwards the agreed answer back to C, standing in for the movement authority reaching IL/the train. A genuine cross-compare disagreement (not just a link-integrity fault) now escalates all the way to RTE_SAFESTATE_LEVEL_REBOOT and triggers a live ONLINE/STANDBY failover between sites, with the faulted site's own A/B genuinely resuming afterward, not just the site-level tag flipping - see "RBC simulation & live failover" below.

Two binaries, three roles, two sites - 6 containers total.** A and B share one binary/source tree/main() (rte_example, started as rte_example A WEST / rte_example B WEST / etc. - src/application/AB/main/main.c parses argv[1] (role: A or B) and argv[2] (site)); C has its own separate binary (rte_monitor_c WEST, src/application/C/main_c.c, argv[1] is just the site - this binary only ever is C). Both binaries share their common process startup (src/application/app_main_common.c). Each (role, site) pair runs as its own OS process/container (not a thread within one process), communicating over real TCP sockets (rte_netlink) - this is the actual redundancy boundary a real deployment would have, not a simulation of one. docker-compose.yml runs the full 6-container topology with each service on its own container, real Docker networking, and Compose's own DNS resolving one container's target host to another by service name - see "Running" below. The same binaries also still run as plain local OS processes (the etc/run_all.sh/.claude/skills/run-RBC_GP/smoke.sh path), all on 127.0.0.1, on distinct ports (src/application/site_config.h) to avoid colliding with each other - every CONNECT-role link's target host is resolved through site_config_host(), which defaults to 127.0.0.1 when the matching RTE_EXAMPLE_*_HOST env var is unset (the local/manual path) and Compose sets to the peer container's own service name (the container path) - see common_config.h's own doc on those env vars.

  • A and B (src/application/AB/main/channel_ab.c), per site: a real cyclic executive - rte_appmanager_run() loops forever (max_iterations = 0), one cycle per simulated 500ms cycle, paced by a real periodic rte::timer (not an ad hoc sleep loop). As of the framework's ADR-019, the eight phases below are split across three rte_appmanager hooks instead of one undivided execute(), so each hook's own job stays visible in its name: pre_execute() covers read channels (read the most recently received M136 from C and decide this cycle's own M24/M15 answer), check timer expiration (rte_watchdog_timer_tick()), check tasks (read whatever a background rte::task has most recently received from the peer - see below), and process data (CRC-64 decode/verify it); execute() covers only cross-compare (AGREE/DISAGREE against the local decision); post_execute() covers prepare data for sending (encode this cycle's own sample, plus the C-forwarding answer if AGREE and role A), dual transfer (send it to the peer over the same full-duplex link the background task receives on), and send to C (only on AGREE, and only from role A - see below). Results are threaded between the three hooks via ctx->cyc_* scratch fields on channel_ab_context_t since they no longer share one function's stack frame. See channel_ab.c's file header for the full phase-by-phase breakdown. A single-channel fault is deliberately injected on role A only, at exactly D_LRBG == RTE_EXAMPLE_DLRBG_FAULT_VALUE (300): A computes the wrong answer (M15 instead of the correct M24) even though its own CRC-64 still verifies fine.

    Also as of ADR-019, A/B's rte_appmanager_config_t::checkpoint is wired in: before pre_execute() each cycle, rte_appmanager_run() calls rte_channel_checkpoint() (ADR-017) against a rte_vital_channel_t wrapping the same peer TCP link cross-compare already uses - not a second connection. Because that link's message_size is fixed and a background task already owns its only reader, checkpoint traffic is multiplexed onto it: every frame now carries a 1-byte kind tag (PEER_MSG_KIND_AB_SAMPLE/ _CHECKPOINT_REQUEST/_CHECKPOINT_REPLY), and the background reader (peer_rx_task_entry()) also auto-responds to the peer's own _REQUEST with a _REPLY (nothing in rte_checkpoint generates that reply automatically). A pthread_mutex_t (peer_send_mutex) guards every send on that link now that both the main thread and the background thread can send on it. rte_vital_channel_init()'s channel_count floor was relaxed (framework-side, ADR-019 addendum) to >= 1 to fit this single-physical-link topology. checkpoint is paused (vital_channel set to NULL) whenever the peer link itself is known down, deliberately not treated as a checkpoint-timeout SAFE-halt - see docs/architecture/ADR-019-...md §5.3 in the framework repo for the full reasoning. Live-verified: the 8-process WEST+EAST demo runs cycle-over-cycle with AGREE/0 errors and no checkpoint timeouts.

    Structured message-trail logging:** every A/B/C application log - AB_SAMPLE sends/receives, M136 reports, checkpoint REQUEST/REPLY, AGREE/DISAGREE decisions, answers forwarded to C, each channel's own inter-site negotiation traffic, and every generic diagnostic (init/shutdown, link/task/timer/watchdog failures, role/mode transitions) - goes through the framework's rte_log_write_event() instead of the free-text rte_log_write(), producing a fixed, space-separated Key=Value line per event - Site=<site> Timestamp=<ms> Level=<LEVEL> Cycle=<n> Source=<src> Destination=<dst> Type=<type> Info=<info>[ <extra_fields>] (e.g. Site=WEST Timestamp=100022194 Level=INFO Cycle=3 Source=A/WEST Destination=B Type=AGREE Info=M24). Site is this process's own WEST/EAST identity (site_config_name(ctx->site)), first in the line so a log consumer merging output from all 6 processes can always tell them apart at a glance; Cycle=0 marks an event with no natural cycle (link/task/timer lifecycle events). Events with no natural cycle still carry a Type category (LINK, TASK, TIMER, WATCHDOG, MODE, ROLE, LIFECYCLE, NEGOTIATION, NEGOTIATION_MISMATCH, CHECKPOINT, XCOMPARE, TRANSFER) so they remain filterable even without a cycle number. Both the console printf trail and this structured OSAdapter line are still emitted side by side - the former for a human watching the terminal, the latter for a machine parsing the Platform_RTE-side log output. Framework-internal diagnostics (e.g. rte_watchdog.c's own "watchdog created"/"watchdog timeout" lines) are outside this application's log calls and are unaffected.

    Since cross-compare already confirms A and B agree, only A forwards the agreed answer to C - B's own agreeing answer would be redundant. A genuine CRC-clean logical DISAGREE (the M24/M15 decision itself differs, not link corruption) is judged more severe than an ordinary value mismatch: both A's and B's own channel independently detect it and both set ctx->own_faulted (in-memory, no shared filesystem between containers), flush that fault to their own inter-site counterpart one last time before their own links close, and enter RTE_SAFESTATE_LEVEL_REBOOT - see "RBC simulation & live failover" below for what happens next.

    The peer receive, and the M136 receive from C, are both asynchronous: background rte::task workers continuously receive from each link and hand each message to the cycle loop through a small lock-free seqlock-style slot (rx_slot_t in channel_ab.c) rather than blocking the 500ms cycle on the network. Because A and B each run their own independent, unsynchronized 500ms timer (no shared clock between them - rte::clocksync exists in the framework and would be the natural next step) - and, on top of that, A (LISTEN) and B (CONNECT-with-retry) start their own cycle timers at two different wall-clock moments during init() - their cycle counts settle into a roughly fixed relative skew for the rest of the run, not just occasional jitter. Cross-compare tolerates a peer report within RTE_EXAMPLE_AB_CYCLE_SKEW_TOLERANCE cycles of the local one (rather than requiring an exact match) so this fixed skew doesn't permanently starve one side of any comparable data; right at the D_LRBG=100 or D_LRBG=300 boundary, this tolerance can in principle produce a spurious DISAGREE (or fail to reproduce a real one) purely from that skew - an honest, documented edge case, not a bug (see channel_ab.c's file header).

    Separately from the logical decision compare, the A<->B peer link itself carries a CRC-64 (rte::checksum) over each sample - a transmission integrity* check. If the CRC-64 doesn't match what actually arrived (link corruption, not a legitimate decision disagreement), the receiving side calls RTE_SAFESTATE(RTE_SAFESTATE_LEVEL_SAFE, ...) (ADR-004) and halts - no handler is registered for SAFE (only for REBOOT - see below), so this is the framework's default defensive infinite-loop halt, not a clean abort()/exit. Only the A<->B link is CRC-64 protected; the A/B<->C links are not (see channel_ab.c's file header for the scope note). Every cycle that completes a send to the peer kicks a real rte::watchdog armed in init().

    Because A/B now run forever, something has to be able to stop them: main.c calls rte_appmanager_install_default_signal_handlers() - Platform_RTE's own SIGINT/SIGTERM -> rte_appmanager_request_shutdown() convenience - for a graceful stop (all links, the watchdog, the cycle timer, and the background tasks are closed down properly, not just killed) - see Running below for how the launcher uses this. Each role module (channel_ab.c/monitor_c.c/site.c) only builds what it does each cycle via a *_make_config() function; main.c is the one place that actually calls rte_appmanager_run(), for whichever role was selected. main.c also registers a RTE_SAFESTATE_LEVEL_REBOOT handler once, for every role, that calls rte_reboot_request().

  • C (src/application/C/monitor_c.c, its own binary rte_monitor_c), per site: an RBC train-position-report simulator, restructured onto the same cyclic-executive shape as A/B (its own free-running 500ms timer). Every cycle it generates D_LRBG = x % RTE_EXAMPLE_DLRBG_MODULUS (x is C's own free-running report counter, optionally started from RTE_EXAMPLE_C_POSITION_START - see Running below) and sends the same M136 to both A and B over the same two links they connect to it on - now bidirectional, not just A/B -> C forwarding. It then reports whatever M24/M15 answer A's background task most recently received (B never sends one - see above), tagged ACTIVE or SUPPRESSED - inferred fresh every cycle from that same staleness tracking (monitor_c_report.c), not a shared-filesystem role read: a STANDBY/FAULTED site's own A/B never forward anything at all, so "no answer from A yet and no B fallback available" is exactly the SUPPRESSED case, with no coordination needed between this C container and its own site's A/B containers - a live failover shows up immediately (see Hot standby below).

There is no standalone SITE role anymore - West/East negotiation is folded directly into A and B themselves, see "West/East negotiation" below.

A LISTENs for B on the A/B link; both A and B CONNECT to C. Since container/process start order across the 6 independently-launched (role, site) pairs isn't guaranteed, the CONNECT side retries on connection refusal for a bounded window (RTE_EXAMPLE_CONNECT_TIMEOUT_MS in src/application/common_config.h) rather than treating an early refusal as fatal - the same pattern each channel's own EAST-side inter-site negotiation link uses (below).

West/East negotiation

Historical; current behaviour: Site redundancy and A/B agreement, section 5.*

No standalone SITE role: each of A and B owns its own, independent inter-site negotiation link to its counterpart on the other site - A-WEST<->A-EAST and B-WEST<->B-EAST are two SEPARATE TCP links, each wrapped in a real rte_dual_channel_t + rte_dual_negotiator_t (ADR-020), so site-level negotiation is never a single point of failure the way one shared process/link would be (src/application/AB/com/channel_ab_negotiate.c). WEST always LISTENs on its own negotiation port (RTE_EXAMPLE_A_NEGOTIATION_PORT/ _B_NEGOTIATION_PORT - fixed ports, not per-site offsets); EAST always CONNECTs (a fixed technical role, unrelated to which site ends up online), retrying for up to RTE_EXAMPLE_CONNECT_TIMEOUT_MS since container start order isn't guaranteed. The negotiator's own older-startup-timestamp-wins tie-break (own_id/peer_id as the deterministic fallback on an exact tie) settles each link's own startup ONLINE/STANDBY outcome independently.

Because A's and B's own negotiation links are genuinely independent (different TCP connections, different negotiator instances, started at slightly different wall-clock moments), they can occasionally settle on different* outcomes for the same site - a real, if narrow, race, not a bug. A and B guard against acting on a disagreed site-role by cross-checking their own independently-negotiated {is_online, single_mode} every cycle over their EXISTING same-site peer link (PEER_MSG_KIND_SITE_STATE - channel_ab_crosscompare.c): a mismatch is itself treated as a fault (own_faulted, RTE_SAFESTATE_LEVEL_REBOOT, RTE_EXAMPLE_REASON_AB_SITE_STATE_MISMATCH) - a deliberate judgment call trading a little availability (occasionally both channels reboot and re-negotiate before settling) for never letting A and B act on a disagreed site-role. See "Honest limitations" below for how often this is actually observed.

Hot standby

Both sites always run their full A/B/C group regardless of the negotiated outcome. Only each site's own C tags its reported output ACTIVE (this site is ONLINE) or SUPPRESSED (STANDBY, or FAULTED - see below); A/B's cross-compare logic is identical either way. C infers this live, every cycle** from whether its own site's A/B are actually forwarding answers at all (see C's own description above), starting from a fail-safe SUPPRESSED default until the first real answer (or lack of one) arrives - this is what makes a live failover (below) visible without restarting C, and needs no shared filesystem between C and its own site's A/B containers.

Framework feature adoption

Two Platform_RTE primitives that used to have no consumer in this example are now load-bearing here, replacing hand-rolled logic that used to duplicate what they already do:

  • rte_cross_comparator (ADR-025): A/B's own AGREE/DISAGREE decision (channel_ab_crosscompare.c) is now a real 2-way peer comparison through this module instead of a hand-rolled local == peer check - see that file's own header for how the "already-arrived, already-CRC- verified" values are adapted into the module's channel-receive shape.
  • rte_dual_channel + rte_dual_negotiator (ADR-020): each channel's own inter-site link (A-WEST<->A-EAST, B-WEST<->B-EAST) uses a real redundant-link transport (built-in ACK-wait, degradation tracking) plus the framework's own state negotiator for the startup ONLINE/STANDBY tie-break, instead of a hand-rolled beacon and comparison. See channel_ab_negotiate.c's own file header for the one deliberate limit on this adoption: the negotiator's state model has no FAULTED value and no way for application code to override its own_state, so this channel's actual authoritative role (ctx->is_online) stays app-level, with the negotiator used for transport, the startup tie-break, and diagnostics, not as the sole source of truth.

RBC simulation & live failover

Historical; current behaviour: Site redundancy and A/B agreement, sections 5 to 7. In particular the negotiation-link watchdog of point 6 no longer exists: a lost inter-site link never reboots or promotes a channel by itself.*

The one-time negotiation above only decides who starts ONLINE. Live failover afterward - "current online goes to safe state and reboots, the other site becomes the new online and continues, and the faulted site itself recovers back into the mix" - works through six cooperating pieces, all now living directly inside channel_ab.c/ channel_ab_negotiate.c (no separate SITE process to relay any of it):

  1. Three-state role, not two: ONLINE / STANDBY / FAULTED (common_config.h), carried entirely in memory (ctx->is_online/ctx->own_faulted on channel_ab_context_t - no shared filesystem between containers). When A or B detects a genuine M24/M15 disagreement, it sets ctx->own_faulted (not plain STANDBY-implying-nothing), flushes that fault to its own inter-site counterpart one last time (channel_ab_negotiate_flush_faulted() - see point 3), closes its own links/tasks/timer/watchdog cleanly (channel_ab_shutdown(), called directly - see its own doc for why: execv() does not close inherited file descriptors on its own, so skipping this would both leak sockets across the reboot and prevent the peer side from ever observing a real disconnect), and enters RTE_SAFESTATE_LEVEL_REBOOT. FAULTED being distinct from STANDBY is what prevents the two sites from flapping back and forth afterward - see point 3.
  2. Mocked hardware reboot + REBOOT itself: app_main_common.c's registered handler first prints a clear *** FAULTED - simulating hardware reboot *** message and sleeps RTE_EXAMPLE_REBOOT_DELAY_MS (5s) - a real reset is not instantaneous, and this makes the fault -> reboot -> resume sequence a visible event in a demo run rather than an easy-to-miss instant re-exec. It then calls rte_reboot_request(), which on this project's POSIX OSAdapter re-execs the current process image (execv(), resolved portably per-OS - /proc/self/exe on Linux, _NSGetExecutablePath() on macOS) with the same original argv (role, site) it was launched with (rte_posix_osadapter_reboot_set_argv()), so the process resumes as the same role for the same site.
  3. Inter-site-carried live role + auto-promotion: each channel opens its own real rte::dual channel (rte_dual_channel_t) to its counterpart on the other site and attaches a rte_dual_negotiator_t to it (ADR-020) for the startup ONLINE/STANDBY tie-break and an ongoing link-health diagnostic - see "Framework feature adoption" above. That negotiator has no concept of FAULTED, though (it models link health, not "my own A/B just found a disagreement"), so a second, small payload is exchanged on the same link every AB cycle carrying the sender's own current application-level role - this payload is also what carries the online->standby state transfer (point 6 below). Three rules, applied on every negotiate step, together keep the system from ever staying stuck:
    • STANDBY + counterpart FAULTED -> promote self to ONLINE (the original takeover rule - a FAULTED channel never promotes itself, which is what stops the flapping described above).
    • FAULTED + counterpart ONLINE -> demote self back to STANDBY ("recovery" - once this channel has had time to reboot and reconnect, and the counterpart is healthy and online, this channel settles back into a normal passive role instead of staying tagged FAULTED forever).
    • FAULTED + counterpart FAULTED -> re-negotiate using the same deterministic own_id-vs-peer_id tie-break the negotiator itself documents as its own fallback (WEST wins). This is not a rare corner case in this demo: both sites' C processes free-run the identical x % RTE_EXAMPLE_DLRBG_MODULUS formula from the same starting x, so on a long enough run both sites' A/B tend to hit RTE_EXAMPLE_DLRBG_FAULT_VALUE within a cycle or two of each other - without this rule the system would get permanently stuck with both sites FAULTED and neither ever promoting. Because a faulting channel's own reboot tears its negotiation link down almost immediately, channel_ab_negotiate_flush_faulted() sends one last, synchronous best-effort FAULTED payload BEFORE that link closes (not just setting ctx->own_faulted and hoping the next regular cycle gets a chance to relay it - there is no next cycle) - without this, the counterpart would never learn about the fault at all and the promotion rule above would never fire.
  4. Standby channels read, but don't decide: only an ONLINE channel's A/B computes an M24/M15 decision, cross-compares, and forwards to C; a STANDBY or FAULTED channel still receives and prints each M136 for visibility, but does not act on it - matching real hot-standby practice (the standby channel observes, the online channel decides).
  5. A/B actually resume, not just the site-level tag: rte_netlink's POSIX LISTEN OSAdapter only ever accepted one peer and then closed the listening socket - a rebooted A or B could not reconnect, and C's own link to a rebooted A/B would fail forever (M136 send ... failed (HARDWARE_FAULT), repeating). This project's own application code (not the framework) now owns retrying: channel_ab.c's peer/C-link receive tasks and monitor_c.c's per-link receive tasks each own their link's whole lifecycle - on disconnect they close the dead handle and keep re-listening/re-connecting forever (bounded per-attempt timeout, so shutdown still notices promptly), rather than ending the task the way this used to work. Each channel's own inter-site negotiation link gets the same treatment on a smaller scale: channel_ab_negotiate_execute() detects a dead send/receive, closes the link, and channel_ab_negotiate_reconnect() retries it (bounded per-cycle attempt, not a blocking multi-second retry that would stall the whole 500ms cyclic executive) until it comes back, re-initializing the dual_channel/negotiator fresh so a genuine re-negotiation happens on reconnect. monitor_c_execute()'s and channel_ab_execute()'s own sends simply skip a link that is currently NULL (mid-reconnect) instead of blocking on it. One related bug caught during this project's own container-topology verification: a rebooted process's cycle counter always restarts at 1, but the surviving peer's own counter never stopped incrementing while the peer was down - without an explicit resync (cycle_resync_requested, set whenever peer_rx_task_entry() (re)establishes the link) the two sides would stay permanently dozens of cycles apart, past RTE_EXAMPLE_AB_CYCLE_SKEW_TOLERANCE, and cross-compare would silently never resume after the very first reboot. 2026-10-04: cycle_resync_requested was dead since RBC_GP 13e56c6 (no setter) and is removed; a peer reboot now relies on the 60-skip REBOOT (ab_gp_channel_crosscompare.c).
  6. Message-and-acknowledgment liveness, on both the A<->B peer link and each channel's own inter-site negotiation link - a DISAGREE (above) means both channels are alive but computed a different answer; this is the separate case of a peer going silent altogether. Both links already exchange data every cycle (A/B's own "dual transfer" phase; each channel's own negotiate-step send+receive), so that existing round trip is the message/ack exchange - no new wire message was introduced for this purpose. Each link now has its own watchdog with its own reaction, deliberately different, because the consequence of losing each link is different:
    • A<->B (channel_ab.c's dual_transfer_watchdog, RTE_EXAMPLE_AB_DUAL_TRANSFER_TIMEOUT_MS): if a channel receives nothing at all from its cross-compare peer for this long, it does not reboot - nothing indicates its own decisions are wrong, unlike a DISAGREE. It drops to SINGLE mode instead: it keeps deciding and, if it is the only one left, keeps forwarding to C solo (monitor_c.c was extended to actually consume a solo answer arriving from B, not just discard it, for the case A - not B - is the channel that went quiet). This only arms/disarms while the site is actually ONLINE - a STANDBY pair legitimately never exchanges A<->B samples at all (point 4 above), so the watchdog is started/stopped on the ONLINE/STANDBY transition edge rather than left running the whole time a site is standby.
    • Inter-site negotiation link (channel_ab_negotiate.c's neg_watchdog, RTE_EXAMPLE_NEGOTIATION_WATCHDOG_TIMEOUT_MS): reacts only if THIS channel currently considers itself ONLINE - an ONLINE channel that can no longer prove it is healthy (or relay a future fault) risks an undetected split-brain, so it sets own_faulted and enters RTE_SAFESTATE_LEVEL_REBOOT (same self-recovering mechanism as points 1-2 above). A STANDBY channel losing the SAME link does not reboot - that is the ordinary, expected shape of its ONLINE counterpart's own planned fault->reboot cycle (point 1's own flush lands right before the link drops), not a fault of the STANDBY channel's own; rebooting it too would needlessly cascade a single-channel fault into a second, unrelated outage. This is a deliberate departure from the old standalone-SITE design, where the negotiation link's own lifecycle was completely independent of A/B's - now that negotiation lives inside A/B, a routine self-recovering reboot naturally takes the negotiation link down with it, so treating every loss as reboot-worthy for BOTH sides stopped being correct.
    • Hot Standby -> Cold Standby tag: purely a status/log change, no behavior change. The negotiate-step payload also carries the sender's own single-mode flag, so a STANDBY channel can log when its counterpart's own A/B redundancy is degraded (its own backing is now only "cold"), and an ONLINE channel running SINGLE can log the same thing about itself.
  7. Online->standby state transfer: takeover (point 3) used to mean the newly-promoted channel started cold at cycle 1, discarding whatever the faulted channel had already computed. Now the ONLINE channel includes a live {cycle, D_LRBG, M24/M15 decision} snapshot directly in the SAME per-cycle negotiate payload as point 3 (no separate NVM region, no separate relay step), and the STANDBY channel caches the latest one it receives. At the moment of promotion, a configurable policy decides what happens to the transferred cycle counter (RTE_EXAMPLE_TRANSFER_POLICY_ENV, restart/continue/ask, default continue), applied directly to ctx->cycle in memory (channel_ab_negotiate.c's own apply_state_transfer()):
    • restart - renumber from cycle 0 (fresh start, transferred D_LRBG/ decision still logged for visibility but the cycle count resets).
    • continue (default) - resume from the transferred cycle number, as if the newly-promoted channel had been running it all along.
    • ask - a bounded (2s) stdin prompt (r/c); no answer (the normal case under automated/container runs with no attached TTY) falls back to continue so the demo never stalls waiting for an operator.

x (C's D_LRBG report counter) only reaches RTE_EXAMPLE_DLRBG_FAULT_VALUE once every RTE_EXAMPLE_DLRBG_MODULUS reports; set RTE_EXAMPLE_C_POSITION_START (an environment variable, e.g. a value close to the fault value) to start C's own counter closer to it for a quick demo/verification run, without changing the generation formula itself.

Running as local processes (no Docker)

Each (role, site) pair is also just a plain OS process; run six instances across the two binaries, in any order, in separate terminals. Every role now runs forever (its own real 500ms/600ms cyclic executive - see Architecture above) until sent SIGINT/SIGTERM (Ctrl+C, or kill):

dist/<platform>/bin/rte_gateway_c_train WEST
dist/<platform>/bin/rte_gateway_c_il WEST
dist/<platform>/bin/rte_gateway_c_ctc WEST
dist/<platform>/bin/rte_gateway_c_status WEST
dist/<platform>/bin/rte_gateway_c_train EAST
dist/<platform>/bin/rte_gateway_c_il EAST
dist/<platform>/bin/rte_gateway_c_ctc EAST
dist/<platform>/bin/rte_gateway_c_status EAST
dist/<platform>/bin/rte_example A WEST
dist/<platform>/bin/rte_example B WEST
dist/<platform>/bin/rte_example A EAST
dist/<platform>/bin/rte_example B EAST

(<platform> is CMAKE_SYSTEM_NAME-CMAKE_SYSTEM_PROCESSOR, e.g. Darwin-arm64.)

Or use the launcher, which starts all six and then runs indefinitely - useful for watching the RBC fault/failover trigger on its own (see "RBC simulation & live failover" above), or forcing it quickly with RTE_EXAMPLE_C_POSITION_START. Ctrl+C (or kill/SIGTERM) stops everything gracefully via the script's own trap:

etc/run_all.sh
RTE_EXAMPLE_C_POSITION_START=95 etc/run_all.sh # force the fault quickly instead of waiting

To see the RBC fault/failover scenario without waiting for x % RTE_EXAMPLE_DLRBG_MODULUS to wrap around to the fault value on its own, set RTE_EXAMPLE_C_POSITION_START close to it, e.g. via etc/run_all.sh (applies to both sites' C, since both share the launcher's environment):

RTE_EXAMPLE_C_POSITION_START=95 etc/run_all.sh

Starting both sites' C at the same offset faults both sites within a cycle or two of each other - this is the realistic long-run case (see "RBC simulation & live failover" above for why) and exercises the full one-side-faults / other-side-faults / both-recover cycle, not just a single-site failover. To see only a single site fault in isolation instead, start just that site's C process directly with the same environment variable set, leaving the other site's C at its default.

Honest limitations

This is a demonstration, not a certified safety application:

  • The local/manual launch path (etc/run_all.sh, .claude/skills/run-RBC_GP/smoke.sh) still runs all six roles on 127.0.0.1, on distinct ports (src/application/site_config.h), to keep that path simple and dependency-free. The Docker Compose path (docker-compose.yml) is the real multi-container deployment - six genuinely separate containers/network namespaces, real container-to- container TCP/IP, no shared filesystem - and needs no source changes to switch between the two: every CONNECT-role link's target host already goes through site_config_host(), which reads the matching RTE_EXAMPLE_*_HOST env var and falls back to 127.0.0.1 when unset (see "Two binaries, three roles, two sites" above).
  • The startup negotiation tie-break (older-startup-timestamp-wins, ties broken by WEST winning) is, in practice, often what decides the outcome on a fast local run where both sides start within the same millisecond - see the West/East negotiation section above. Because A's and B's own negotiation links are independent, they occasionally settle differently for the same site during startup or right after a simultaneous both-channel fault (see that section's own note on the PEER_MSG_KIND_SITE_STATE cross-check) - observed in practice as one or two extra reboot-and-renegotiate cycles before the whole topology settles, not a hang or a lost message; a smoke-test run launched immediately back-to-back with another one (i.e. under heavier-than- normal concurrent process load on one machine) is where this is easiest to reproduce - see .claude/skills/run-RBC_GP/smoke.sh's own "Known flake" comment.
  • rte_watchdog's recovery action is still RTE_WATCHDOG_ACTION_LOG for the plain link-liveness watchdogs (ctx->watchdog in channel_ab.c) - a genuine link hang there is logged, not force-crashed. The message-and-acknowledgment watchdogs added later (A<->B's dual_transfer_watchdog, each channel's own inter-site neg_watchdog - see "RBC simulation & live failover" point 6 above) DO react for real now (RTE_WATCHDOG_ACTION_FAILOVER, dispatching to an application callback - single mode for A/B's own dual-transfer loss, FAULTED + REBOOT for an ONLINE channel's own negotiation-link loss), so this limitation only still applies to the original plain link watchdogs.
  • channel_ab.c's background-task-to-cycle handoff (rx_slot_t) uses a hand-rolled seqlock, not a qualified RTOS mutex/queue primitive - this framework does not (yet) expose one to application code. See that struct's own doc comment for the reasoning and the defensive property (a torn read would very likely also fail the CRC-64 check) that makes this an acceptable demo-grade choice, not something to treat as SIL-rated synchronization.
  • A/B and C's reconnect loops (see "RBC simulation & live failover" above) are an application-level retry policy layered on top of rte_netlink's still-simple POSIX OSAdapter (each LISTEN re-open is a fresh rte_netlink_open() call, relying on SO_REUSEADDR - there is no framework-level "accept a second peer on the same still-open listening socket" primitive). This works and was verified live end to end, but is not the most efficient possible design (each reconnect attempt is a new socket, not a persistent listener that hands off new peers) - acceptable for a demo, not necessarily for a real deployment with much higher reconnect-frequency requirements. Each channel's own inter-site negotiation link now gets an equivalent, bounded-per-cycle reconnect loop too (channel_ab_negotiate_reconnect()) - an improvement over the old standalone SITE role, whose one-time negotiation link never retried at all once established and lost.
  • The negotiation-link watchdog's own reaction (point 6 above) only ever closes and lets channel_ab_negotiate_reconnect() retry the link for a STANDBY channel - it does not itself detect "the counterpart is gone for good, not just transiently slow" the way a real deployment's own operator/orchestrator alerting would. In this demo that is a reasonable trade (retrying forever costs nothing but a background reconnect attempt every cycle), but it means a genuinely, permanently dead counterpart leaves the STANDBY channel quietly retrying forever rather than raising any distinct alarm.
  • Shutdown latency for A/B/C grew from near-instant to bounded-but-not- instant (up to a few seconds) as a direct consequence of the above: a background task that retries a dead link forever can no longer be unblocked just by closing its handle out from under it (that would race with its own reconnect logic), so shutdown now sets an explicit stop_requested flag and waits for the task to notice it at its own next loop-check point instead. See channel_ab_shutdown()'s and monitor_c_shutdown()'s own doc comments for the full reasoning.
  • Verified failover + recovery trace (WEST initially ONLINE, both sites' C started near the fault value so both fault within a cycle or two of each other - the realistic long-run case, not an artificially staggered one): A/WEST and B/WEST log DISAGREE ... entering REBOOT, both flush FAULTED to their own inter-site counterpart, A/EAST and B/EAST each log *** TAKEOVER ... promotes itself to ONLINE *** and state transfer: policy=... applying {cycle=...}; moments later EAST faults the same way and WEST takes back over while EAST recovers to STANDBY in turn. Both faulted channels print the 5s mocked-reboot message, re-exec, and their peer link and C link both log (re)established; cross-compare then logs AGREE again and forwarding to C resumes, continuing indefinitely with no process left dead - see .claude/skills/run-RBC_GP/smoke.sh's own failover scenario for a scripted, repeatable version of this same trace.
  • No SIL-level V&V has been done for this project. A real cppcheck --addon=misra pass has been run against this project's own source (cmake --build build --target cppcheck, via Platform_RTE's CMake target) and found several hundred un-triaged findings - mostly the same guard-clause single-point-of-exit style and diagnostic printf/<stdio.h> use as the rest of this demo, neither of which is claimed to be MISRA-clean anywhere in this project. None of it has been triaged rule-by-rule or fixed; see Platform_RTE's own docs/MISRA_COMPLIANCE_REPORT.dox for how that report handles the same situation for the framework's own source, which this project's posture does not automatically inherit.
  • The reboot mechanism itself (rte_posix_osadapter_reboot.c) is explicitly a stand-in, not a real safety-relevant reboot path: a normal process cannot reset the CPU, so execv() re-exec (restarting the process image from main()) is the closest available approximation. A real deployment on real hardware needs a real reset mechanism (watchdog-triggered hardware reset, supervisory process that respawns this one, etc.).

See ../../platform/Platform_RTE/docs/architecture/ADR-018-posix-oal-osadapter.dox (kept there as a historical record; its content now applies to src/posix_osadapter/ in this repository - see that file's own header for the relocation note) for what the POSIX OSAdapter itself does and does not guarantee.