Failsafe real-time runtime components for industrial edge devices, in C++20.
A runtime that executes motion commands under a safety supervisor, on Linux, in containers — with the deadline behaviour measured rather than assumed, and the no-allocation rule enforced rather than documented.
| Work package | Component | State |
|---|---|---|
| WP-08a | concurrent — wait-free SPSC ring |
Done |
| WP-08b | rt — cyclic executor, latency histogram, allocation guard |
Done |
| WP-09a | safety — black-channel telegram, CRC, fault model |
Done |
| WP-09b | safety — IEC 61800-5-2 supervisor, 1oo2D dual channel |
Done |
| WP-09c | Requirements traceability, FMEA, CI gate | Done |
| WP-10 | ipc — zero-copy shared-memory transport |
Done |
| WP-11 | edge — container packaging, metrics, dashboard |
Done |
| WP-11b | External emergency stop, acknowledgement, timestamped safety signal | Done |
| WP-11c | opcua — OPC UA server, in its own process |
Done |
218 core tests cover the runtime, including seven C allocator guard checks.
CI runs the core suite under Debug, Release, ASan+UBSan and ThreadSanitizer.
The encrypted OPC UA configuration adds twenty-six more for 244 tests —
fourteen on security and certificate verification, eight on transition events,
four on the address space — plus a libFuzzer target on the telegram decoder. The shared-memory tests
genuinely fork() rather than simulating a second process with a thread.
65 requirements, every one linked to implementing code and verifying tests, with a CI gate that fails on a broken link.
docker compose -f deploy/docker-compose.yml up -dGrafana on :3000, Prometheus on :9090, the runtime's metrics on :9100.
docker compose exec safeedge touch /tmp/safeedge-estopTorque is withheld. Now remove the request:
docker compose exec safeedge rm /tmp/safeedge-estopNothing restarts. That is not an oversight. IEC 60204-1 requires that restoring an emergency stop device must not by itself restart a machine, so coming back needs a second, deliberate act:
docker compose exec safeedge touch /tmp/safeedge-ackThe two inputs behave differently on purpose. The stop is level-triggered, because it is a condition. The acknowledgement is one-shot and consumed on read, because an acknowledgement left asserted would clear the latch again on the next cycle — and a fault that cannot stay latched is not latched.
Neither is polled from the control loop. A stat() is cheap until the
filesystem decides otherwise — an NFS mount, a full disk, a container layer
under pressure — and a control loop whose period depends on the filesystem is
not a control loop. A watcher thread publishes to an atomic the loop reads for
free.
curl -s localhost:9100/safety
# 2 0 472652975507Sequence, torque permitted, and the monotonic instant the runtime entered this state.
That last field exists because the pickcell integration proved it was missing.
/readyz answers "should traffic come here" and carries no time, so a consumer
polling it can only stamp its own observation — and the reaction time it computes
comes out near zero, which does not mean the link is fast, it means there is
nothing to measure. A signal that cannot be timed cannot be held to a
deadline.
With it, the whole column can be measured end to end. safeedge decides, the cell stops, and the interval between them is a real number: min 5.7 ms, median 48.7 ms, max 80.7 ms across a 100 ms poll — none of which is a property of this runtime, which decides in about a millisecond. The interval is the link.
The instant is CLOCK_MONOTONIC, which shares an epoch across processes on a
machine, and it is served as text rather than as a Prometheus gauge on purpose:
a nanosecond monotonic value is around 1e18, and a gauge rendered to six
significant digits would be wrong by hundreds of millions of nanoseconds.
/metrics carries the age instead, which is a small number and renders
exactly.
cmake -S . -B build -DSAFEEDGE_BUILD_OPCUA=ON && cmake --build build -jSAFEEDGE_SNAPSHOT_SHM=/safeedge-runtime ./build/safeedged &
SAFEEDGE_SNAPSHOT_SHM=/safeedge-runtime ./build/safeedge-opcuaopc.tcp://localhost:4840, browse to SafeEdgeRuntime: safety state, torque
permitted, emergency stop, the safety sequence, wakeup jitter percentiles, cycle
counters, and whether real-time scheduling was granted.
It is a separate process on purpose, and off by default. OPC UA brings a
session layer, two encoders, a certificate handler and a subscription engine.
Any of that failing — a parse bug on a malformed request, an unbounded
allocation, a certificate library calling abort() — must not be able to take
down a loop holding a machine in a safe state. Putting it behind a process
boundary makes that structural rather than aspirational: no shared heap, no
shared allocator, nothing in common but a shared-memory page.
It also keeps safeedged at 1.51 MB on scratch with no userland, which the
container job verifies by unpacking the layers. Linking a protocol stack into
the failsafe binary would end that.
The snapshot crosses between them through the ipc component — the same seqlock
that serves the in-process HTTP handlers, so there is one publish rather than
two.
The namespace index is not a constant. The first version hardcoded 1 and
failed to build its address space at all. An index is a per-server lookup;
index 0 is the base namespace, 1 is conventionally the server's own application
URI, so a custom one usually lands at 2 — and "usually" is the problem. The URI
is the contract, the index is a lookup, and a client resolves
urn:safeedge:runtime at connect time. The test asserts only that the index is
non-zero, because asserting a number would enshrine the assumption that failed.
A bad status does not clear a value. The first version wrote hasValue = false with BadNoData, expecting a client to get nothing. open62541 keeps the
previous value, so a client reading the value attribute would still see a dead
runtime's torque permitted, indefinitely — with a correct status nobody was
obliged to read. Both channels now carry the message: the status says "I do not
know", and the value is the zeroed snapshot, so a client that ignores the status
still reads not permitted.
That is the same rule this codebase has now arrived at three times in three
vocabularies — UNSPECIFIED at zero in the protobuf contracts, the writer
heartbeat on the shared-memory link, and a StatusCode paired with a fail-safe
value here. The absence of information must never be representable as
permission.
safeedge-opcua-probe subscribes and times its own notifications against the
instant the runtime decided — the same quantity pickcell measures over HTTP.
| request | granted | median |
|---|---|---|
| 10 ms / 10 ms | 100 ms / 50 ms (open62541 stock limits) | 74.9 ms |
| 10 ms / 10 ms | 10 ms / 10 ms (this server's limits) | 12.1 ms |
Same client, same request, six times the difference — decided entirely by
publishingIntervalLimits on the server. A client asking for 10 ms is quietly
given 100 ms; revisedPublishingInterval says so, and a client that never reads
it reports the interval it wished for. This server lowers both floors and the
probe prints any revision it receives.
That is the third protocol here to need the same discipline, after
pthread_setschedparam succeeding while granting nothing and SCHED_FIFO being
refused by an unprivileged container: read back what you were granted.
Two more corrections on the way there, both recorded in the ADR: a
subscription's first notification reports state, not an event (the first run
measured 7107 ms, which was the age of the state), and
UA_Client_run_iterate(client, timeout) is the client's poll — the first
probe passed 100 ms and spent its time measuring its own loop.
A subscription is not inherently faster than a poll. It removes the client's poll. The server-side intervals remain, and they are somebody's configuration rather than a property of the protocol.
The first version ran unencrypted and warned about it at startup. A warning is not a control, so the server now either has a certificate or does not start.
SecurityPolicy#None is removed unless SAFEEDGE_OPCUA_INSECURE=1. That
matters more than it sounds: open62541 adds None alongside the encrypted
policies, so a server configured "with encryption" will happily serve plaintext
to any client that asks — and clients ask, because it works.
A certificate is loaded from SAFEEDGE_OPCUA_CERT / SAFEEDGE_OPCUA_KEY if
provided, and generated at startup if not. Generation is logged as a warning,
not an info line, because it means every client must re-trust the server after
each restart — and that friction is exactly what teaches operators to disable
certificate checking. There is no silent fallback to plaintext: if a certificate
cannot be obtained, the server fails.
One failure worth the retelling. The generated certificate's
subjectAltName said urn:safeedge:opcua while open62541 defaults the server's
ApplicationUri to urn:open62541.server.application. OPC UA requires them to
match — a certificate is a claim to an identity, and a server presenting one has
to claim that identity. The symptom: the server started, built its address space,
logged listening, and then the event loop exited with
BadCertificateUriInvalid. Everything up to fatal looked correct. One constant
now feeds both, and a test asserts every endpoint advertises it.
Client authentication, and a claim this README previously got wrong. It used
to say client certificates were pointless here because anonymous login is
permitted, so validating one "authenticates nobody". That conflates two things.
A trust list is checked at the SecureChannel, against the client's
application certificate, before any session exists — a client not in it is
refused with BadCertificateUntrusted and never gets as far as offering a user
token. Anonymous is about which user is behind an application already admitted.
What actually makes a trust list meaningless is a SecurityPolicy#None
endpoint beside it: that one needs no certificate, so every client the list was
meant to exclude connects to the other endpoint while the server goes on
reporting that it authenticates. Both that pairing and an empty trust list are
now refused at startup rather than documented as caveats — checking against
nothing has two readings, trust everyone and trust no one, and both are worse
than saying so. Set SAFEEDGE_OPCUA_TRUSTLIST to enforce it; the accept-any
default is logged as a warning, because an operator who assumed otherwise
should find out from the log rather than from an intrusion.
scripts/demonstrate-client-auth.sh runs the real server against two real
clients, one trusted and one not, and greps the specific status code rather than
the word "certificate" — which also appears in the startup line and would have
made the check unfailable. It carries a negative control: that rejection must
not appear when no trust list is set. User identity remains anonymous.
The probe verifies the server against the DER certificates in --trust-list DIR.
Provision a stable server certificate with SAFEEDGE_OPCUA_CERT and
SAFEEDGE_OPCUA_KEY, and place the trusted certificate in that directory before
connecting:
safeedge-opcua-probe --endpoint opc.tcp://localhost:4840 --trust-list ./server-trustMissing or unusable trust material fails closed. For a local demonstration with
a temporary server identity, --insecure-accept-any-server-cert explicitly opts
out of server authentication and prints a warning. Both paths require an
encrypted channel, and the connected probe prints the negotiated SecurityPolicy
and whether the server certificate was verified. The demonstration scripts use
the explicit insecure flag because their server identities are temporary.
A subscription on a variable delivers samples, not changes. The server reads
the node every samplingInterval and reports it when it differs from last time,
so a state that begins and ends between two reads is absent rather than late —
the client has no way to learn it existed. The server therefore also raises a
SafetyTransitionEventType event on every transition. Measured with both
subscriptions in one client session against one server, twelve transitions
in states lasting 100 ms with the client sampling at 500 ms:
| delivered | |
|---|---|
| events | 12 of 12 |
| data-change notifications | 4 |
| coalesced by the server | 0 |
Events are usually sold as a latency optimisation. Both subscriptions here share one publishing interval, so the events arrive no sooner — what differs is how many arrive at all. The difference is correctness, and the latency is the same.
Three limits, stated rather than implied. An event queue overflows too: it is
lossless up to the depth the client asked for, which is weaker than lossless and
stronger than anything a sampler can promise. The chain is only as event-driven
as its most sampled link — this server reads its own snapshot on a poll, so a
transition shorter than that never reaches OPC UA at all. And that case is
reported: the snapshot carries a transition counter, so when it advances by
more than one the server puts the gap in MissedTransitions rather than
presenting the newest state as the whole story. Same argument as BadNoData on
a stale variable — the absence of information has to be representable, or it
gets read as permission.
Encryption costs about 4 ms on the subscription path — 14.0 ms median against 10.1 ms unencrypted, same intervals. Small, real, and measured rather than assumed in either direction.
Full reasoning in ADR-0010,
including the two mbedTLS build settings that are not optional and why
UA_STRING_STATIC cannot be used in this codebase.
cmake --preset debug
cmake --build --preset debug
ctest --preset debugPresets: debug, release, asan, tsan, tidy.
Before pushing, run the formatter -- CI enforces it:
scripts/format.shIt pins clang-format 18; a different major version formats differently and CI will reject the result.
Validate the workflow definition before pushing as well:
actionlint .github/workflows/ci.ymlA YAML error prevents GitHub Actions from creating any jobs, so the workflow cannot lint its own broken definition and there will be no job log to inspect.
actionlint validates the local workflow structure, but it does not resolve
remote uses: references. The Trivy scanner is therefore pinned to the full
commit behind a published upstream release, with the release name beside it in
the workflow. When updating that pin, verify against the upstream
Trivy Action releases
that the release tag resolves to the recorded commit. A missing reference fails
Set up job before checkout, so none of the image build, scan, or smoke-test
steps run.
To see what the executor actually achieves on your machine:
cmake --build --preset release --target rt_cycle_report
./build/release/examples/rt_cycle_report 1000 5A wait-free single-producer/single-consumer ring for the boundary between the real-time control thread and everything without a deadline. Bounded instruction count, no locks, no allocation, no syscalls, no retry loop. Cache-line padded, with each side caching the other's position.
Memory ordering is acquire/release, derived edge by edge in ADR-0002 and validated by ThreadSanitizer — x86-64 would hide an incorrect pairing.
A fixed-period loop with absolute-deadline scheduling. Each deadline comes
from the previous deadline, never from the current time, and the wait is
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME).
CyclicExecutor::Config config;
config.period = 1ms;
config.thread.policy = SchedulingPolicy::kFifo;
config.thread.priority = 80;
config.thread.cpu = 2;
config.thread.lock_memory = true;
CyclicExecutor executor(config);
const ThreadConfigReport report = executor.configureCallingThread();
if (!report.scheduling_applied) {
// Do not just carry on. Latency figures gathered without the scheduling
// you asked for describe the machine's mood, not your code.
log(report.what());
}
auto control_step = [&](const CycleContext& ctx) { plant.update(ctx); };
executor.run(CycleCallback(control_step));Why this matters is measurable rather than theoretical. Median wakeup delay on
the development host at 1 kHz is 78 µs. The naive sleep_for(period) loop
adds that to the schedule permanently — it would run at ~928 Hz and fall 4.7
seconds behind after one minute, with every individual cycle looking fine.
Absolute deadlines turn that into jitter, which is a measurement, instead of
drift, which is a defect. ADR-0003.
CycleCallback is two pointers, not a std::function — a type that can
allocate has no business in the signature of a real-time API.
Linking safeedge::rt_alloc_guard replaces the global operator new family.
An allocation inside a marked scope aborts, or is counted, by policy.
{
const NoAllocScope no_alloc;
controller.step(); // aborts if anything in here reaches the allocator
}This exists because the allocation is almost never visible at the call site: a
std::function assignment whose capture outgrew the small-object buffer, a
push_back that allocates only on the growth step, a std::string built on an
error path. Those pass review, pass tests, and fail in the field under load.
It has already earned its place — it is what proves the claims other components
make. SpscRing says it never allocates; that is now a passing test rather than
a sentence in an ADR. ADR-0004.
The Linux RT, safety and IPC test harnesses also wrap direct malloc, calloc,
realloc and free calls at link time. C allocation attempts reach the same
guard; free is forwarded without counting a new allocation. The test variant
avoids counting operator new's backing allocation twice. This instrumentation
is not linked into production binaries or installed as a public target.
Link wrapping covers calls from the executable and statically linked objects,
not allocator calls hidden inside shared libraries. The production guard still
intercepts only operator new.
The transport is assumed to be entirely untrustworthy and given no safety responsibility at all. Every defence lives in a thin layer at each endpoint, which is what lets safety traffic run over ordinary, uncertified networking hardware. Same architecture as PROFIsafe, openSAFETY and FSoE.
PROFIsafe-inspired, not PROFIsafe. It borrows the mechanisms because they are the right ones and are documented in IEC 61784-3. It is not conformant, not certified, and not interoperable with a real PROFIsafe device.
Every fault in the IEC 61784-3 model is defended and injected by a test:
| Fault | Defence |
|---|---|
| Corruption | CRC-32/AUTOSAR, HD=6 |
| Unintended repetition | Consecutive number |
| Incorrect sequence | Consecutive number |
| Loss | Sequence gap, plus watchdog when all traffic stops |
| Unacceptable delay | Watchdog |
| Insertion | Source address folded into the CRC, never transmitted |
| Masquerade | Same |
| Addressing error | Destination address folded into the CRC |
Three of those a CRC alone cannot touch, and they are the ones a home-grown protocol usually misses. A replayed telegram is byte-for-byte valid — the checksum is correct because the data really is what the producer sent, just not when it sent it. A delayed one is authentic and correctly ordered and simply describes a world that has moved on. Masquerade and misaddressing both look like perfectly good frames; the defence is that the CRC is seeded with source, destination and a parameter signature that are never put on the wire, so anything that does not already share them cannot produce a matching checksum.
That is not cryptography — those parameters are commissioning data, not secrets. It defends against faults, not adversaries.
Any fault latches a safe state and receive() then hands over nothing until
acknowledged. Deliberately inconvenient: real faults are intermittent, so a
consumer that recovered on the next good telegram would ride through a failing
transceiver indefinitely while the machine ran on data that was only sometimes
trustworthy. ADR-0005.
Implements STO, SS1-t, SOS, SLS, SLP. Explicitly not implemented: SS2, SBC, SDI, SLI, SMS, SSM, SAR, SLA — each needs drive hardware this does not model.
Evaluation order inside a cycle is fixed and load-bearing:
- Unconditional demands — E-stop, communication loss, watchdog loss. These override every state and every request. Not inputs to be arbitrated; conditions under which permission is not available to grant.
- Fault latch and acknowledgement.
- Per-state monitors. Before requests, so a host repeatedly requesting the function it is currently violating cannot ride through the violation.
- Requested transitions.
Three properties worth calling out:
Requests are level-triggered. A function stays active only while still
requested; dropping to kNone releases it. The alternative is equally
defensible and the difference is sharp — under edge triggering a silent host
leaves the machine restricted, which is safe; under level triggering it releases
the restriction, which is not. That moves the entire burden of detecting a
silent host onto the watchdog, which is why communication_ok and
heartbeat_ok are unconditional demands rather than advisory inputs.
Acknowledgement clears a fault but does not restart motion. A separate enable is required. Collapsing the two is how an acknowledgement button becomes a start button.
The first cause is retained, not the latest. Once torque is removed the axis decelerates and reliably produces follow-on violations; reporting the most recent one would send a technician to the wrong subsystem.
Every default is fail-safe: a default-constructed SafetyInputs describes a
machine with the E-stop pressed and no communication.
1oo2: either channel alone can demand the safe reaction. Every field of the combined output takes the safe direction, so no channel can grant a permission its peer withholds. D: the channels are cross-compared every cycle, so a channel that has failed in a way it cannot itself detect shows up as a disagreement.
Disagreement is tolerated briefly — independent sensors have independent noise, and at a threshold boundary they will legitimately differ for a cycle or two. Faulting on the first disagreement makes the machine unusable; never faulting makes the diagnostics decorative.
The limitation that matters most: both channels run the same code. Real 1oo2D uses diverse implementations, because two identical ones share identical systematic faults — a logic error produces the same wrong answer in both, they agree perfectly, and cross-comparison reports everything is fine. This defends against random hardware faults and divergent sensor input. It does not defend against a software defect, and running the same function twice never will.
None of this is certified, and none of it is a safety case. A real installation needs a qualified implementation, an assessed process and a notified body. What it demonstrates is that the functions, their interactions and their failure modes are understood.
Two kinds of data move between the runtime's processes, and they want different
structures. Queued data, where every item matters, uses SpscRing.
State, where only the newest value has meaning — current joint position,
current safety state — uses a seqlock. Conflating them is a common and expensive
mistake: a queue applies backpressure to preserve values nobody wants, and on a
real-time producer backpressure is either a blocked deadline or a silent drop.
Measured cross-process round trip, 64-byte messages, 20 000 iterations:
| Transport | p50 | p99 | p99.9 |
|---|---|---|---|
| shared memory (seqlock) | 446 ns | 587 ns | 6.3 µs |
| Unix stream socket | 27 968 ns | 85 025 ns | 241 580 ns |
| pipe | 27 313 ns | 81 959 ns | 229 763 ns |
Tens of times faster at the median — but the tail is the interesting column. Nearly three orders of magnitude tighter at p99.9, and for a cycle that has to fit inside a deadline, the tail is what decides whether it fits.
Two costs, both real:
It burns a core. Shared memory does not make the work cheaper, it moves the cost from a kernel wakeup to a spinning reader. The benchmark reports the child's CPU time next to the latency for that reason — and even that understates it, because the benchmark keeps both sides busy. A consumer polling for messages arriving every millisecond spins through the other 99% of the time.
It gives up isolation. A peer with the region mapped can write anything anywhere in it, at any time. That is the price of zero copy. Where the peer is not trusted, the black-channel CRC and sequence number apply exactly as they do over a network — the safety layer does not care whether the untrusted transport is Ethernet or a page of memory.
The seqlock is race-free by construction: the payload is an array of atomics copied word by word, not plain memory read while a writer may be writing it. The textbook version is a data race by the letter of the standard, TSan reports it, and the usual responses are to suppress the sanitiser or shrug. Reader retries are bounded, because a writer that died mid-update leaves the counter odd forever and an unbounded loop would hang a reader inside its own control cycle.
ADR-0008 records the ordering — and
that GCC's ThreadSanitizer cannot instrument std::atomic_thread_fence, which
is why the ordering is expressed per-operation rather than with fences.
The runtime image is 1.51 MB and contains exactly one file — the statically linked binary. No shell, no package manager, no libc. An image with no userland cannot have a userland vulnerability, which is a stronger property than keeping one patched.
CI enforces that rather than asserting it: it unpacks the image layers and fails
if they hold anything but safeedged, with an 8 MB budget so a debug tool added
"just for now" fails the build instead of quietly staying.
The cost is real: there is nothing to docker exec into. That works here only
because the diagnostics were designed for it — structured JSON logs and a
metrics endpoint carrying everything the runtime knows about itself.
SCHED_FIFO needs CAP_SYS_NICE. A container that drops every capability
cannot do real-time scheduling — it starts, serves metrics, passes its
healthcheck, and silently misses deadlines. Same image, two runs:
| Run | safeedge_realtime_scheduling_granted |
|---|---|
--cap-drop=ALL |
0 |
--cap-drop=ALL --cap-add=SYS_NICE --ulimit rtprio=99 |
1 |
Security hardening and real-time behaviour are in direct tension, and the
default advice — drop everything — silently costs the runtime the property it
exists for. The compose file adds SYS_NICE back deliberately with the reason
written beside it.
The mitigation is not the comment, though. It is that
safeedge_realtime_scheduling_granted is the first metric emitted and the
first, largest panel on the dashboard: every latency figure next to it is
meaningless when it reads 0, and a dashboard showing microsecond jitter without
it would be actively misleading.
/healthz asks whether the process is functioning. /readyz asks whether it is
fit to be used. A latched safety fault makes it not ready but still
healthy — because restarting the container would throw away the fault
information an engineer needs, and restart a machine whose safety supervisor had
just decided it should not be running.
docker stop completes in 829 ms against a 10 s grace period. That matters:
SIGKILL would mean the runtime never reaches a safe state, and PID 1 has no
default signal disposition, so the handler has to be installed explicitly.
ADR-0009.
Fixed-size, allocation-free, logarithmic bucketing at constant ~3.1% relative precision. Percentiles are reported as the upper bound of the containing bucket, so a reported p99.9 is a value the true p99.9 cannot exceed — for a latency budget, erring high is the safe direction.
Median of 7 repetitions, CPU pinning verified.
| Configuration | median | min | max |
|---|---|---|---|
| Single thread, push+pop (cache hot) | 1471.6 | 715.2 | 1666.8 |
| Cross-thread, padded + cached positions | 353.9 | 309.0 | 413.4 |
| Cross-thread, shared cache line, no caching | 224.8 | 153.0 | 264.5 |
M items/s. Layout advantage 1.57x at the median. Both rows run the same algorithm and the same memory ordering; the only difference is cache-line layout. That is the entire performance argument for the class, so it is measured against a control implementation in the same binary rather than asserted.
Verified rather than asserted: exhaustive detection of every single-bit and
every double-bit error in a 32-byte message, heavy sampling at three, four and
five bits, and every burst up to 32 bits. The standard check vector
(0x1697D06A for "123456789") is asserted so the implementation is
identifiably CRC-32/AUTOSAR and not something that merely resembles it.
The classic CRC-32 polynomial used by Ethernet and zip drops to HD=4 above 91
bits — four flips can produce a valid checksum. 0xF4ACFB13 holds HD=6 up to
2048 bits. For a checksum whose failure mode is "the machine acts on corrupted
motion data", two extra guaranteed bits is not marginal.
Output of rt_cycle_report 1000 5 on the development host. Read the
conditions block before the numbers — that is why the program prints it first.
- Scheduling requested: SCHED_FIFO priority 80
- Scheduling obtained: SCHED_OTHER priority 0 -> DENIED
- CPU pinning: verified
- Memory locked: yes (mlockall)
- Allocation guard: installed
- Virtualised host: YES
- Diagnostics: pthread_setschedparam failed (errno 1)
| Measurement (ns) | min | p50 | p99 | p99.9 | max |
|------------------------|---------|---------|----------|-----------|------------|
| wakeup jitter | 58802 | 77823 | 135167 | 499711 | 38618434 |
| callback execution | 36 | 103 | 431 | 639 | 18484 |
| deadline to complete | 58840 | 77823 | 135167 | 499711 | 38619194 |
- Cycles executed: 5000
- Overruns: 2 (0.0400%)
- Deadlines skipped: 51
- Allocation violations: 0
Two things worth reading off that table.
The wakeup jitter is the platform, not the code. SCHED_FIFO was denied
(no CAP_SYS_NICE under WSL2), so the loop ran on the ordinary time-sharing
scheduler. 78 µs of median jitter against a 1 ms period is what that costs.
Callback execution — the part this code is responsible for — sits at 103 ns
median and 639 ns at p99.9. The report says DENIED in the header for exactly
this reason: a runtime that believes it has real-time scheduling when it does
not produces latency figures that are fiction, and that fiction surfaces as an
unexplained field failure rather than a test failure.
Zero allocation violations across 5000 cycles. The executor holds its own contract, and the guard was verifiably linked while it did.
Defensible tail figures need tuned bare-metal Linux with isolated cores, which
is what rt-latency-lab exists to
provide.
65 requirements in docs/safety-requirements.md,
each linked to implementing code and verifying tests by source annotations:
// @satisfies REQ-SAF-023 in include/ or src/
// @verifies REQ-SAF-023 in tests/ or fuzz/docs/traceability-matrix.md is generated from
those annotations and committed, and CI fails if a requirement has no
implementation link, no verification link, if an annotation or an
FMEA row names a requirement that does not exist, or if the
committed matrix is stale.
The unknown-id check is the one that matters. A hand-maintained matrix is
accurate the day it is written and decays from then on; a generated one that
cannot detect its own broken links is worse still, because it manufactures
confident evidence nobody has checked. An annotation reading REQ-SAF-O23
(letter O for zero) attaches to nothing — so the negative case is exercised
rather than assumed, and produces two failures: the unresolved reference at its
exact line, and the requirement it abandoned.
The FMEA deliberately carries no RPN numbers. Occurrence scores need field data this component does not have, and inventing them produces arithmetic that looks like measurement. Severity is stated qualitatively because it genuinely is known; detection is stated as the mechanism, which is what a reviewer actually needs. One row — a shared systematic software defect across both channels — is listed with no covering requirement, because there is none it could honestly be traced to. ADR-0007.
| Gate | Why |
|---|---|
GCC + Clang × Debug + Release, -Werror |
-Wconversion and -Wold-style-cast fire on different constructs per compiler |
| ThreadSanitizer | The only gate that validates ADR-0002. TSan models the C++ abstract machine, so it catches ordering bugs x86-64 forgives |
ASan + UBSan, -fno-sanitize-recover=all |
A finding fails the build rather than printing a note |
clang-tidy --warnings-as-errors=* |
Actionable correctness rules stay blocking; reviewed POSIX-boundary exclusions are justified in ADR-0011 |
scripts/format.sh --check (clang-format 18) |
Formatting never reaches review, and local checks use the same file set as CI |
| install + downstream consumer compile | The public CMake contract is tested, not assumed |
| requirements traceability | Every requirement implemented and verified; no dangling or invented ids. Scans the Dockerfile and compose file too, because some requirements are implemented there and nowhere else |
| container: layers, size, run, stop | Image holds exactly one file, stays inside an 8 MB budget, serves its endpoints hardened, and honours SIGTERM inside 5 s |
| Trivy scan + SBOM | Nothing to find in an image with no userland — asserted rather than assumed |
| libFuzzer on the telegram decoder | The only code here that parses bytes it did not produce |
- Memory ordering is validated on x86-64 only. x86-64's strong memory model will hide an ordering bug that AArch64 would expose, and AArch64 is where industrial edge devices increasingly are.
- C allocator coverage is test-only and limited to link-visible calls. The
production guard covers
operator new; calls inside shared libraries and indirect C allocation APIs such asstrdupare outside the link-wrapper guarantee. - The guard cannot see an allocation the compiler elided. [expr.new]/10 lets an implementation omit the allocation call when it can prove the result is unused, so Release reports fewer violations than Debug. Harmless in production — an elided allocation costs nothing — but it means any test asserting on allocator behaviour must make the allocation observably necessary. Two of these tests did not, passed in Debug, and failed the first Release run.
- No latency number here was taken on real-time-capable hardware. Every figure above is labelled with the conditions it was gathered under, and the tail figures are not claimed as properties of this code.
Tests assert structural properties that hold on any host — exact cycle counts, drift-free deadline arithmetic, correct overrun accounting, absence of allocation, and ordering and loss guarantees under real thread contention.
They deliberately do not assert latency thresholds. These tests run on shared,
virtualised CI runners without real-time privileges; a test asserting "jitter
under 50 µs" there fails for reasons unrelated to the code, and a flaky test is
worse than no test because the team learns to re-run it. Latency belongs in the
benchmark and in rt-latency-lab, on hardware where the number means something.
Where a tolerance is loose, the reason is written down. Where a gap exists, it is in this README rather than only in the author's head.
- Nothing on the deadline path may block, allocate, or enter the kernel. Every component is judged against this before anything else — and the allocation half is mechanically enforced, not trusted.
- Measure, then claim. No performance statement here is unaccompanied by the code that produced it and the conditions it was taken under.
- Report what actually happened, not what was requested. Scheduling policy, CPU affinity and memory locking are all read back from the kernel and reported, because in containers and VMs they routinely do not apply.
- Constrain the problem rather than generalise the solution. The runtime is genuinely SPSC, so the queue is SPSC — which removes the CAS loop, the ABA hazard and reclamation outright. Less subtlety under a safety function is a feature.
- A decision without a written alternative is not a decision. Every ADR names what was rejected and why.
Apache-2.0.