Ludus assertion subsystem design¶
Status: implementation contract. M0/M1 and the approved M2/M3 hardening and
closed formatting layer are implemented, together with narrow M4 byte-writer
integration. See the adversarial audit and evidence.
An amendment adds resumable enabled ASSERT/ASSERT_F in eligible non-CI
builds (§5.1,
§5.2,
ADR 0006,
the authoritative interactive-development plan,
and the interactive-development spec).
That behavior is now implemented: ASSERT/ASSERT_F split from the terminal
fatal path onto a resumable one, a debugger Continue (Debug or Development, non-CI)
or an explicit external-helper Continue-once (non-CI Debug, no debugger) returns
past the assertion, and the SDK assertion-policy version is bumped to 2.
REQUIRE/FATAL stay terminal in every build (including after a debugger
Continue) and CHECK stays boolean.
The M0/M1 record is historical. Rich crash collection,
stack capture, asynchronous logging hooks, and other-platform backends remain
prospective; successful tests alone are not production-readiness certification.
Original repository inspection: 64e5bfd36a2e, September 23, 2026. The observations
below are the design baseline, not a claim that prerequisite defects still exist.
The reconciliation plan identified the actual
test exception flag-order defect; M0 corrected it, along with Profile identity
and SDK assertion configuration. See the implementation evidence for current facts.
1. Recommendation¶
Put a small assertion runtime in FoundationBase, with private OS backends.
Expose a template-free assert.hpp for conditions and literal messages, and an
opt-in assert_format.hpp for a deliberately limited typed diagnostic format.
Use macros only to preserve expression text, capture the caller, suppress
evaluation, and place message evaluation behind the failure branch. Compile
formatting, reporting, debugger detection, and termination once in .cpp files.
An enabled REQUIRE/FATAL invariant never returns on failure, including
after a debugger resumes execution. An enabled ASSERT/ASSERT_F is terminal
by default, but is resumable through explicit developer action in a tightly
scoped configuration — locally built, non-CI Debug builds only (see the
interactive-development policy
and ADR 0006). A
recoverable CHECK returns false; its caller must implement a valid recovery
branch. None of these forms replaces normal error handling. Report through an
independent emergency path first; normal logging and a future crash reporter are
optional consumers of the already-built report.
This is a handful of headers and implementation files, not a handler framework, general formatting library, replacement string library, or crash reporter.
The critical review of Steve Rabin's GPG1 chapter records which historical ideas informed this design and which were rejected. Its useful additions are reflected below: meaningful context, debugger usability, report handoff without a debugger, and suppression of presentation only.
2. Repository findings and constraints¶
These findings record the initial audit used to design the failure runtime. Later module implementations can expand those capabilities; the current logging guide describes its delivered scope. Assertion reporting continues to use its independent FoundationBase path.
| Area | Observed implementation | Design consequence |
|---|---|---|
| Base | modules/foundation/base is a static library, with fixed-width aliases in types.h, version APIs, and UniquePtr |
Assertions must be usable inside Base without linking upward |
| Dependency direction | Logging publicly links Base; Platform publicly links Base and Logging | Base must not include Platform or Logging; OS primitives needed by diagnostics live below the windowing layer |
| Compiler helpers | defines.h exposes LUDUS_INLINE and a concept via <type_traits>; defines.hpp duplicates inline/type definitions and adds build detection |
Do not include both, or make assertions inherit their transitive dependencies; consolidate only the necessary compiler helpers |
| Header export | Base's public file set exports defines.h, not defines.hpp |
Assertion behavior cannot rely on the latter being installed |
| Logging API | log.hpp includes <format>, <source_location>, <string_view>, <utility>; config.hpp adds <filesystem> |
Do not include log.hpp from any assertion header |
| Logging execution | logger.cpp is synchronous; Asynchronous is reserved but not implemented |
Do not describe existing logs as queued, or pretend an async drain API exists |
| Logging failure path | VLog creates a std::string through std::vformat; dispatch and flush acquire a shared mutex and invoke sinks |
Calling LUDUS_LOG_FATAL is not a safe implementation of an assertion |
| Fatal logging | Fatal records are flushed and copied to emergency output after normal dispatch; the logging call itself returns | LUDUS_FATAL must be distinct from LUDUS_LOG_FATAL |
| Emergency logging | Private EmergencyLog uses a 512-byte buffer, snprintf, fwrite, and fflush; Windows also uses debugger output |
Useful behavior to preserve, but stdio locks/blocking prevent treating it as a hardened crash primitive |
| Thread identity | Logging's private thread ID is an engine counter; thread names use a TLS std::string |
Assertion handling must not initialize that TLS state; capture a native thread ID independently |
| Platform | Current implementation is Wayland/headless windowing; OS classification is in platform/config.h |
There is no reusable low-level debugger, crash, or stack-capture service yet |
| Memory and strings | No custom allocator or native general-purpose string/view exists; UniquePtr is present |
Introduce only a diagnostic pointer/length view, not speculative allocator/string infrastructure |
| Error handling | Window APIs already return bool, use out-parameters, and are noexcept |
Preserve explicit error paths; assertions diagnose programmer defects |
| Builds | Clang 23/LLD, C++23, extensions off, exceptions disabled; tests alone re-enable exceptions | Runtime entry points are noexcept; compiler intrinsics are narrowly guarded platform implementation details |
| Profile | linux-clang-profile selects RelWithDebInfo; current CMake consequently defines LUDUS_BUILD_DEVELOPMENT, not LUDUS_BUILD_PROFILE |
Use an explicit assertion setting; fix build-flavor identity separately |
| SDK | Project options are linked privately with BUILD_INTERFACE |
Export assertion configuration deliberately; it will not automatically reach installed consumers |
Read these sources before implementation:
- Contributor rules and steering rules.
- Standard-library policy.
- Logging type-erasure decision.
- Build profiling.
- Logging entry points, runtime, and emergency output.
- Build options, target defaults, and presets.
ADR 0004 records approximately 19 s to 9 s of aggregate logging-header parse time after moving heavy formatting instantiations out of the header. Those are historical repository measurements, not measurements of this proposal. Its remaining header cost is exactly the failure mode assertions must avoid. Some milestone/README descriptions predate the present windowing and logging code; use source and CMake as the implementation baseline.
3. Goals, non-goals, and error classification¶
Goals¶
- One condition evaluation when enabled; none when disabled.
- No runtime work beyond the condition and a conditional branch on success.
- No allocation, locks, TLS access, formatting, or debugger polling on success.
- No C++ exceptions, iostreams, public STL types, or heavyweight public includes.
- Preserve expression, file, line, short function name, failure kind, native thread ID, and bounded diagnostic text.
- Work before logger initialization and after shutdown.
- Fail predictably from worker/render threads and within diagnostics itself.
- Keep build cost and emitted code measurable and bounded.
Non-goals¶
- Recovering from arbitrary memory corruption or invalid diagnostic pointers.
- A signal-safe general assertion API, stack-overflow recovery, or GPU-side shader assertions. Those require separate fault entry points.
- Remote commands, per-site ignore registries, runtime handler chains, or
automatic debugger attachment. Resumable
REQUIRE/FATALinvariants remain a non-goal: those forms are terminal in every build. A single, non-persistent "Continue once" decision for enabledASSERT/ASSERT_Fis an explicit, tightly scoped exception introduced by ADR 0006; it is not a general dialog/handler framework or a per-site ignore registry (see §5.1). - A general-purpose formatter, arbitrary object printers, reflection, or allocation-heavy symbolication on the failing thread.
- Guaranteed log durability in the presence of a hung kernel/device/process.
What belongs here¶
| Situation | Mechanism | Why |
|---|---|---|
| Internal texture pointer must exist by construction | ASSERT, or REQUIRE if shipping must enforce it |
Programmer invariant |
| Internal index could otherwise cause memory corruption in shipping | REQUIRE before the dangerous operation |
An actual branch, not an optimizer assumption |
| Expensive debug-only graph consistency traversal | ASSERT |
Deliberately absent from Profile/Release |
| Internal cache inconsistency has a documented safe rebuild path | if (!CHECK(...)) { rebuild or return failure; } |
Unexpected defect with explicitly valid recovery |
| Impossible enum/state after internal validation | FATAL |
No valid continuation exists |
| Malformed asset/network packet, missing file, unsupported feature | Status/result/optional plus contextual logging | Expected external failure, even if rare |
| GPU/device loss | Device-loss result and recovery/shutdown policy | A driver/device outcome is not proof of an engine invariant defect |
| Recoverable allocation failure | Fallible allocation API and explicit failure return | Assertions cannot provide memory recovery |
| Compile-time layout/type property | Language static_assert |
No runtime machinery needed |
Validate external indices with ordinary branches before use. An assertion can
add an internal post-validation invariant, but must never be the only input
validation. FATAL is appropriate only after a deliberate policy decision that
the process cannot continue, not merely because an operation returned an error.
Allocation failure inside diagnostic infrastructure drops optional detail; it
must not turn a recoverable CHECK into a fatal engine failure.
4. Taxonomy, public names, and exact behavior¶
Use ludus::foundation::diagnostics for assertion-specific types and functions.
Use the existing Ludus aliases, PascalCase types/functions/members, #pragma
once, and lower-case implementation locals consistent with surrounding code.
| API | Result | Failed condition | Condition evaluated |
|---|---|---|---|
LUDUS_ASSERT(condition [, literal]) |
Statement | Report, optional debugger break, terminate — resumable by explicit developer action in local non-CI Debug builds (§5.1) | Only when development assertions are enabled |
LUDUS_REQUIRE(condition [, literal]) |
Statement | Same, in every build | Exactly once, every build |
LUDUS_CHECK(condition [, literal]) |
bool expression |
Best-effort report, optional development break, return false |
Exactly once, every build |
LUDUS_FATAL(literal) |
Nonreturning statement | Unconditional fatal report | Not applicable |
Corresponding _F forms |
Same as above | Typed formatting, exclusively on failure | Same rules |
ASSERT, REQUIRE, and CHECK permit no message. FATAL requires a reason.
The base forms accept a string literal or a valid NUL-terminated borrowed
message, without brace interpretation. Prefer literals. _F requires a
literal-format array and zero or more supported values. Define only the small
family above: no VERIFY, ENSURE, ASSERT_ALWAYS, UNREACHABLE, or competing
synonyms until an actual requirement distinguishes their semantics.
REQUIRE means “required invariant,” not user-input validation. CHECK is
always evaluated but is not a release-safe replacement for an assertion unless
the caller handles false. Bare CHECK statements should be rejected in review
and flagged by the project's future lint rule; [[nodiscard]] on a failure
helper alone cannot reliably diagnose a discarded macro conditional expression.
#include <ludus/foundation/base/assert.hpp>
LUDUS_ASSERT(texture != nullptr);
LUDUS_ASSERT(ownerThread == currentThread, "Wrong command-list owner");
LUDUS_REQUIRE(writeOffset <= capacity, "Corrupt internal buffer offset");
if (!LUDUS_CHECK(cacheGeneration == expectedGeneration, "Stale internal cache"))
{
InvalidateCache();
return false;
}
// Invalid external input uses normal control flow, in every build.
if (packetIndex >= packetCount)
{
return DecodeStatus::InvalidIndex;
}
#include <ludus/foundation/base/assert_format.hpp>
LUDUS_ASSERT_F(index < count, "Invalid index: {} of {}", index, count);
LUDUS_REQUIRE_F(bytes <= remaining, "Need {} bytes; have {}", bytes, remaining);
LUDUS_ASSERT_F(texture != nullptr, "Texture address: {}", DiagnosticAddress(texture));
LUDUS_FATAL_F("Invalid internal command state {}", static_cast<uint32>(state));
Examples assume the appropriate namespace imports and enclosing functions. Do not put required work in a condition or message:
// Incorrect: the initialization disappears when ASSERT is disabled.
LUDUS_ASSERT(InitializeRenderer());
// Correct: execute it, then apply the application's explicit error policy.
const bool initialized = InitializeRenderer();
if (!initialized)
{
return false;
}
Failure messages must be observational, bounded, and safe to evaluate in the failure state. They are evaluated at most once, and may be skipped when recursive or concurrent reporting is suppressed. No application behavior may depend on them. As with ordinary function arguments, do not depend on argument evaluation order; the API does not promise left-to-right evaluation.
Pure query functions are permitted in conditions; banning every function call
would prevent useful invariant checks. A query must be valid in the calling
context and have no required side effects. A message should explain the violated
contract and supply distinguishing values (resource ID, expected/actual state,
owner, generation), not merely repeat the condition or manually embed a function
name already captured in metadata. Do not encode messages using
condition && "message", negate a literal for an unconditional failure, or
re-evaluate the condition to obtain diagnostic values. Use the separate message
parameter, explicit _F arguments, and FATAL respectively.
5. Build configuration contract¶
| Configuration | ASSERT / ASSERT_F |
REQUIRE, CHECK, FATAL |
Break for fatal failure when debugger attached | Break for failed CHECK |
ASSERT resumable |
|---|---|---|---|---|---|
| Debug | Enabled | Enabled | Yes | Yes | Yes (non-CI): debugger Continue, or no-debugger dialog |
| Development / sanitizer development | Enabled | Enabled | Yes | Yes | Debugger Continue only (non-CI); no dialog |
| Profile | Disabled | Enabled | Yes | No | n/a |
| Shipping / Release / MinSizeRel | Disabled | Enabled | Yes | No | n/a |
Profile excludes optional checks so profiling represents release workloads;
REQUIRE still protects critical invariants. Expensive optional checks can be
enabled in a separately named diagnostic profiling build, with that fact in
its build metadata. A debugger break for a REQUIRE/FATAL failure is an
inspection opportunity, never an authorization to resume past the failed
invariant. A debugger break for an enabled ASSERT/ASSERT_F failure is
resumable in Debug and Development when not under CI (continuing returns past
the assertion); the no-debugger Continue-once dialog is additionally
available only in a non-CI Debug build. Under CI, and for every other kind,
continuing a break still proceeds to termination.
Add numeric LUDUS_ENABLE_ASSERTS, LUDUS_BREAK_ON_CHECK, and
LUDUS_ASSERT_DIALOGS_AVAILABLE values to a tiny generated public
assert_config.hpp. LUDUS_ASSERT_DIALOGS_AVAILABLE is 1 only for a
non-CI Debug build (generated from the explicit flavor and the CI build setting)
and 0 elsewhere; it expresses build-time eligibility for the no-debugger
interactive dialog in §5.1.
Whether a given run actually resumes is a further runtime decision (CI veto,
attached debugger, or an explicit helper Continue-once). A debugger Continue can
resume ASSERT in Development too, so it is deliberately not gated by this
value. Adding this policy value bumps LUDUS_ASSERT_POLICY_VERSION (now 2) and
is recorded in the SDK manifest. Generate the header per configured SDK variant;
export the matching include directory and file through Base's public file set.
The source build and installed SDK must use the same values. Do not infer policy
from NDEBUG, DEBUG, or defines.hpp, and do not permit per-TU overrides of
this generated header. Fail configuration on invalid values or conflicting
build flavors. Multi-config SDKs need separate configuration-specific generated
include roots and packages; do not install one flavor over another.
Set the Profile preset's assertion values explicitly now. In the implementation
change, make build flavor a real CMake setting so Profile is not mislabeled as
Development. The current CONFIG:Profile expression does not solve this for a
RelWithDebInfo preset. Keep assertion policy independent of log filtering.
Mixing assertion settings in public inline/template definitions risks an ODR violation even if class layouts match. Treat the SDK variant as part of its build contract, record it in the SDK manifest, and test the installed consumer. Changing assertion settings intentionally recompiles consuming TUs.
5.1 Interactive development: resumable ASSERT¶
This subsection is authoritative for the resumable-ASSERT behavior introduced
by ADR 0006. It
supersedes, for ASSERT/ASSERT_F only, the earlier blanket statement that an
enabled invariant assertion never returns. REQUIRE, REQUIRE_F, FATAL, and
FATAL_F are unchanged and terminal in every build; CHECK/CHECK_F are
unchanged and recoverable.
What "resumable" means. A failed enabled ASSERT/ASSERT_F may, through an
explicit per-hit developer action, return to its caller instead of terminating.
It is a single "Continue once" decision, never a persistent "ignore always,"
never automatic, and never a recovery mechanism the program may depend on. Code
after a resumed ASSERT runs with the invariant known broken; this is the
price of the workflow and the reason it is confined to Debug and to ASSERT.
Where it is eligible (build-time). LUDUS_ASSERT_DIALOGS_AVAILABLE is 1
only in a non-CI Debug build; it gates the no-debugger dialog path only.
Profile/Release do not compile ASSERT at all. Development compiles ASSERT and
follows the plan's execution matrix: with a debugger attached (non-CI) an
ASSERT break and a debugger Continue resume just as in Debug; without a
debugger Development is report-only and terminates (it never opens a dialog
and needs no display/helper). The dialog is the Debug-only difference, not the
debugger break.
Whether it actually resumes (runtime). Even in an eligible Debug binary, a
failed ASSERT resumes only when all of the following hold at the moment of
failure:
- Not CI. The process must not detect a CI environment. A CI detection vetoes resume and forces the terminal fatal path, even for a Debug binary that was built locally and later executed under CI. This "runtime CI veto" prevents a prompt from hanging a runner or a continue from masking a defect.
- Explicit action, by exactly one mechanism:
- Debugger attached: the existing inspection breakpoint is the resume
point. Continuing past the break returns from the failed
ASSERTto the caller. (ForREQUIRE/FATALthe break still falls through to termination — unchanged.) - No debugger attached: present a separate decision prompt offering exactly Continue once and Terminate. "Continue once" returns to the caller for that single hit; "Terminate" takes the normal terminal fatal path. This prompt exists only in a local, non-CI Debug build with usable interactive I/O.
- Interactive I/O is available. If the prompt cannot be presented — no controlling terminal, headless/detached run, closed or non-interactive stdin/stdout, or the prompt is delegated to a helper that is absent or has exited — the outcome is Terminate. There is no default-continue and no silent skip.
Ordering with the existing failure runtime. Resume is decided after the
owned report has been constructed and published and after the primary emergency
write attempt, i.e. at the point where the current design breaks-then-
terminates. The complete report and the minimal fatal header are committed
before any prompt is shown, so an interrupted or faulting prompt still leaves
the full diagnostic evidence. A resumed ASSERT must release the reporting slot
and clear its per-thread entry state exactly like a completed CHECK, so a
subsequent failure on any thread can acquire the slot again; it must not leave
the process in the "fatal owner committed" state. Recursion and owner contention
are never resumable: a recursive ASSERT on the same thread, or an ASSERT
that fails while another failure owns the reporting slot, takes the immediate
secondary/recursive termination path with no prompt. The REQUIRE/FATAL
primary fatal packet is still never released or reused; a resumable ASSERT
that continues must not have consumed that terminal packet.
Report lifetime across a resume. Because ASSERT can now return, its report
storage must follow the recoverable-CHECK lifetime rules, not the terminal
fatal-packet rules: owned bytes valid only for the synchronous report, no
retained borrowed pointers, and no publication into the single-incident terminal
crash packet. A resumed ASSERT counts against the same presentation budget
discipline as CHECK so a hot resumed ASSERT cannot flood diagnostics; the
condition is still evaluated exactly once per encounter regardless of budget.
Runtime split and success-path surface. ASSERT/ASSERT_F use their own
private detail::BeginAssert / FinishAssert (and FinishAssertRendered /
FinishAssertArgs) entry points that are not declared [[noreturn]];
REQUIRE/FATAL keep the terminal BeginFatal / FinishFatal* pair. The
ASSERT/ASSERT_F macro expansions are otherwise unchanged and the success path
is untouched: no success-path helper call, TLS, atomic, lock, clock, debugger or
CI query, allocation, or per-site state. The resumable decision lives entirely in
assert.cpp's cold FinishAssert path via a private ResolveAssertDecision,
which reuses the detached OS-primitive boundary (QueryDebugger /
BreakForDebugger), the shared IsContinuousIntegration() CI query, and — only
under LUDUS_ASSERT_DIALOGS_AVAILABLE — the versioned control endpoint
(see §5.2). None of
this adds anything to assert.hpp or any public header; the failing thread runs
no UI code.
Report lifetime and CHECK budget interaction. In the current implementation
a resumed ASSERT uses a bounded stack-owned report delivered through the
nonblocking transport (never gFatalPacket); it releases the slot and clears TLS
exactly once, so a later failure is independently reportable. On Terminate it
publishes that already-built report into the single-incident fatal packet without
re-evaluating anything, then aborts. CHECK's existing report budget is
unchanged, and CHECK inspection breaks are additionally suppressed under CI.
5.2 Startup and report delivery (external diagnostic helper)¶
This section records the original M0/M1 startup baseline. Interactive assertion continuation was added later under the current policy; see the diagnostic launch guide for current commands.
This subsection is authoritative for the startup/report-delivery layer that
supports interactive development. It is implemented independently of, and ahead
of, the resumable-ASSERT decision itself: this layer establishes the channels
and delivers reports, and it deliberately does not change any assertion's
fatal action. See the
startup validation record for exactly what
was tested.
External diagnostic helper. Interactive presentation and durable report
capture are the job of a small, separate external process — a Python
development helper at tools/diagnostics/ludus_diagnostic_helper.py (installed
as ludus_diagnostic_helper), never the engine's failure handler, window
manager, render thread, main-thread queue, or normal logger. It uses only the
Python standard library for this milestone; the graphical Continue-once /
Terminate dialog (Zenity) is a later milestone. The helper owns the collector
ends of two channels, launches the engine binary with the engine-side
descriptors, and cleans up when the engine exits:
- Report channel — a connected
AF_UNIX/SOCK_DGRAMpair. This reuses the existing nonblocking datagram report transport (§11); the engine end is configured withConfigureEmergencySocketand written withTryWriteEmergencyBytes. The helper drains report datagrams to its own output soASSERT/FATAL/CHECKreports and formatted messages are visible/captured independently of normal Logging, including before logger initialization and after shutdown, and it keeps draining while any future dialog is open. - Control channel — a separate connected
AF_UNIXpair (SOCK_SEQPACKETon Linux; framedSOCK_STREAMon macOS) so report traffic can never masquerade as a decision reply. Its wire format is an explicit little-endian byte encoding, not a compiler-padded C++ struct: a fixed 16-byte header (magic,version,kind,incidentId,length) followed bylengthpayload bytes, encoded/decoded throughEncodeControlHeader/DecodeControlHeader. At healthy startup the engine sendsHelloand the helper repliesHelloAck; a mismatch or absent ack leaves the endpointFailedand the engine runs report-only. TheDecisionRequest/DecisionReplykinds (carrying an incident id and bounded owned report bytes / a one-byte decision) are defined in the layout for a later explicit-decision milestone but are not sent by this milestone's runtime. This IPC protocol version is independent of the terminal fatal-packet version.
Startup-only integration. The integration lives in a small target
above FoundationBase — Ludus::DiagnosticsIntegration
(tools/diagnostics/, header ludus/diagnostics/session.hpp) — which depends on
Base; Base does not depend on it, and it pulls in neither Logging nor
Platform. InitializeDiagnosticSession() is called once, early in main,
before engine workers and before logger setup. It reads the inherited
descriptors from the environment (LUDUS_DIAGNOSTIC_REPORT_FD,
LUDUS_DIAGNOSTIC_CONTROL_FD), configures both Base transports, resolves the
presentation mode, and returns an explicit status. It never launches the
helper or any UI — configuration only — and adds nothing to any engine public
header or to the assertion success path.
Descriptor ownership. Both transports duplicate the inherited descriptor
with F_DUPFD_CLOEXEC and retain the owned copy for process lifetime; there is
no replacement or teardown, so a published descriptor can never race a failure.
The caller may close its originals. The helper owns and closes the collector
ends on child exit.
Mode resolution and the CI veto. Interactive presentation is eligible only
in a local, non-CI, resumable (Debug) build; Development, Profile, and Release
are report-only and need no display or dialog helper. CI is auto-detected
(§12) and always forces report-only —
even for a locally built Debug binary executed under CI — and CI workflows also
set the noninteractive policy explicitly. A local headless Debug run can opt into
report-only (LUDUS_DIAGNOSTIC_INTERACTIVE=0 or the API's ReportOnly).
Validate capability, not availability. When interactive is eligible and
intended, startup validates a usable channel — a controlling terminal, or a
live Linux display connection probed through the Wayland/X11 socket
(macOS currently uses a terminal only) — not
merely that a dialog executable exists on PATH. If validation fails, startup
degrades to report-only and reports the failure visibly (through the ordinary
blocking emergency writer, not the nonblocking assertion transport). Failed
interactive setup never aborts startup and never changes an assertion's action.
6. Header and binary architecture¶
File ownership¶
modules/foundation/base/
include/ludus/foundation/base/
types.h existing aliases; cstddef/cstdint only here
compiler.hpp small guarded cold/noinline attribute helpers
assert.hpp Site, detail entry declarations, base macros
assert_format.hpp diagnostic value tags/overloads, small pack adapter
diagnostic_hooks.hpp rare bootstrap/bridge API; never included by assert.hpp
diagnostic_output.hpp narrow emergency byte-output API for Logging
generated include root/
ludus/foundation/base/assert_config.hpp
src/
assert.cpp policy, ownership, report construction, termination flow
diagnostic_format.cpp parser and scalar conversion, compiled once
diagnostic_output.cpp bounded output routing
diagnostics_linux.cpp Linux OS primitives
diagnostics_windows.cpp Windows OS primitives when port is supported
diagnostics_macos.cpp macOS OS primitives when port is supported
internal/
diagnostic_record.hpp private fixed packet / formatting interfaces
diagnostic_platform.hpp private OS backend contract
modules/foundation/logging/src/
diagnostic_bridge.cpp optional adapter; no Base dependency upward
These files belong to the existing static Base target; no new standalone module or higher-level Platform dependency is necessary. Low-level diagnostic OS calls are foundational services, distinct from windows/events. If a broader platform core emerges later, move their implementation below both Base and Platform; do not create that library speculatively now.
Public header graph:
assert.hpp ------------> types.h ----------> cstddef, cstdint
|---------------> compiler.hpp (no includes)
`---------------> assert_config.hpp (only numeric configuration)
assert_format.hpp -----> assert.hpp (no additional STL includes)
Logging .cpp ---------> diagnostic_hooks.hpp / diagnostic_output.hpp
Logging --------------> FoundationBase
Platform -------------> Logging + FoundationBase
compiler.hpp supplies only portability helpers needed by these declarations,
such as LUDUS_NOINLINE and LUDUS_COLD; use empty fallbacks where unsupported.
Use ordinary C++ [[noreturn]] and [[unlikely]] directly. Do not make a new
compiler-detection framework. Resolve the existing duplicate inline macro in a
small explicit prerequisite change if sharing it, not by including both old
defines headers. Do not change the no-GNU-dialect policy: guarded intrinsics and
attributes are the same narrow portability practice as today's LUDUS_INLINE.
What belongs in the hot header¶
- Small
AssertionSiteandDiagnosticTextaggregates, a byte-sized failure-kind enum, and the declarations of non-template failure entry points. - Minimal macros, configuration include, and compiler attributes.
- No handler registry, locks, atomics, buffers, OS headers, logging categories, clock APIs, string utilities, format parser, stack unwinder, or initialization.
- No build revision/timestamp include. Only the runtime
.cppreads build metadata; changing a revision must not invalidate every assertion consumer.
Types crossing this C++ ABI are scalars, tagged unions, pointer/count pairs, and function pointers. This is a narrow same-toolchain SDK ABI, not a promise of C ABI compatibility or cross-version binary stability. Version the rare hook table with size/version fields; do not serialize pointer-containing structs to disk as a wire format. A future DLL build must have one runtime instance; linking private copies into plugins would split failure ownership and hooks.
7. Source location and expression preservation¶
namespace ludus::foundation::diagnostics
{
struct DiagnosticText
{
const char* Data;
usize Size;
};
struct AssertionSite
{
const char* File;
const char* Function;
const char* Expression; // nullptr for an unconditional fatal
uint32 Line;
};
enum class FailureKind : uint8
{
Assert,
Require,
Check,
Fatal
};
}
Construct the site in the macro's failure branch using __FILE__, __LINE__,
__func__, and #condition. __func__ intentionally captures the short
function name, not a large template signature. Restrict runtime assertion use
to function bodies (including constructors/destructors and initialization
lambdas); use static_assert at namespace/class scope. A source wrapper macro
must forward the user's location, rather than capturing inside its helper.
| Alternative | Advantages | Costs / reason not selected |
|---|---|---|
std::source_location |
Small standard facility; clean default-argument capture, already used by Logging | Adds a public STL type/include and implementation-specific representation; macros are needed anyway; no meaningful simplification here |
__FILE__, __LINE__, __func__ |
No include or template cost; portable macro call-site capture; transparent ABI | Function-body restriction; explicit aggregate |
__builtin_FILE/LINE/FUNCTION defaults |
Useful for function APIs that need caller capture | Compiler abstraction and fallback work without eliminating expression/elision macros |
| Rich signature / pretty-function builtins | More detail for overloads/templates | Potentially large repeated strings; reserve for debugger symbols |
Compiler location builtins are available for future function-based APIs, but are not required for this design. Clang documents both their caller-capture behavior and debugger/trap intrinsics in its language extensions reference.
Stringification preserves texture != nullptr without programmer repetition.
It preserves source tokens, with the preprocessor's whitespace normalization;
it does not automatically record operand values. Avoid expression-decomposition
templates: they multiply overloads, change operator behavior, and harm builds.
Pass useful values explicitly to _F.
Stringify in the outer user-facing macro before forwarding; otherwise macro
operands may expand before stringification. Parenthesize conditions containing
template/initializer commas: LUDUS_ASSERT((Matches<A, B>(value)));. Every
macro reference to the evaluated condition appears exactly once; #condition
does not evaluate it.
Use build prefix mapping to make file strings repository-relative, verified on
Clang 18 and each port. Keep relative file/line and expression strings for
shipping REQUIRE/CHECK/FATAL; do not introduce a hashed-site database yet.
Disabled assertion macros must reference none of their parameters, so their
expression, file/function metadata, and format literals need not survive in
the object. Measure retained .rodata; do not assume linker string merging.
Never place secrets or private user data in diagnostics.
8. Macro/function boundary and evaluation order¶
The following is representative interface/pseudocode. Failure entry points are
visible for macro expansion but remain in detail; callers must not invoke
their two-stage protocol directly.
namespace ludus::foundation::diagnostics::detail
{
// Copies the site; establishes recursion/ownership before message evaluation.
void BeginFatal(FailureKind kind, const AssertionSite& site) noexcept;
bool BeginCheck(const AssertionSite& site) noexcept;
[[noreturn]] void FinishFatal(const char* message = nullptr) noexcept;
bool FinishCheck(const char* message = nullptr) noexcept; // always returns false
}
// In the real header, all detail symbols are fully qualified.
#define LUDUS_DETAIL_SITE(expressionText) \
(::ludus::foundation::diagnostics::AssertionSite{ \
__FILE__, __func__, (expressionText), \
static_cast<::ludus::foundation::uint32>(__LINE__)})
#if LUDUS_ENABLE_ASSERTS
#define LUDUS_ASSERT(condition, ...) \
do \
{ \
if (!static_cast<bool>(condition)) [[unlikely]] \
{ \
::ludus::foundation::diagnostics::detail::BeginFatal( \
::ludus::foundation::diagnostics::FailureKind::Assert, \
LUDUS_DETAIL_SITE(#condition)); \
::ludus::foundation::diagnostics::detail::FinishFatal(__VA_ARGS__); \
} \
} while (false)
#else
#define LUDUS_ASSERT(...) ((void)0)
#endif
#define LUDUS_CHECK(condition, ...) \
(static_cast<bool>(condition) ? true : \
(::ludus::foundation::diagnostics::detail::BeginCheck( \
LUDUS_DETAIL_SITE(#condition)) \
? ::ludus::foundation::diagnostics::detail::FinishCheck(__VA_ARGS__) \
: false))
REQUIRE uses the enabled assertion expansion with FailureKind::Require,
unconditionally. FATAL uses the same body without a condition and an expression
pointer of nullptr. _F macros substitute FinishFatalFormatted or
FinishCheckFormatted; their Begin call still comes before evaluating any
format argument. Plain macros forward an optional message to a defaulted
parameter; C++23 variadic macros permit the empty pack. Use __VA_OPT__(,) only
in helpers that actually require an optional separator; no GNU comma swallowing.
Two-stage entry is justified narrowly: it records the site and establishes the
recursion guard before diagnostic argument expressions run. A one-stage
Fail(site, fmt, ExpensiveDiagnostic()) cannot guard a recursive assertion in
that argument. BeginFatal writes a minimal primary header before optional
message evaluation, so a fault during argument evaluation still has context.
The implementation keeps a trivial, constant-initialized TLS entry state:
depth, kind, copied site, and ownership flag. It allocates no TLS string or
dynamic object. Finish requires a matching active Begin on the same thread;
violations use an internal emergency termination path, not another assertion.
FinishCheck releases ownership and clears TLS before returning false.
There is no exception unwinding or cancellation boundary between Begin and
Finish. Hooks/diagnostic expressions must not use longjmp, coroutine suspension,
or thread cancellation through this region.
The boolean macro needs no capturing lambda, per-site lambda type, overloaded
boolean operator, or statement-expression extension. static_cast<bool> accepts
explicit boolean conversions and avoids a user-defined operator!. A failed
condition is evaluated before Begin, so recursion inside the condition itself
cannot be guarded by the reporting runtime; conditions are normal engine code.
An enabled assertion can appear in a constexpr function: the passing branch
can remain constant-evaluable. A failed assertion attempts a non-constexpr
function call and therefore fails constant evaluation; no custom compile-time
error framework is necessary. Use static_assert when its diagnostic is better.
Disabled assertions use ((void)0), not sizeof(condition), if constexpr
(false), an assume, or an unreachable builtin. They neither type-check nor
evaluate their arguments; separate enabled-build CI catches stale expressions.
9. Strings and formatting¶
Selected strategy¶
Base assertions take literal/borrowed C strings. Formatted assertions require
the explicit _F spelling and assert_format.hpp. That opt-in is intentional:
the common ASSERT(ptr) must not inherit formatting machinery, and changing
formatting support should not rebuild TUs that only use plain assertions.
Use the diagnostic-only DiagnosticText view from assert.hpp and a closed
tagged value set in assert_format.hpp:
enum class DiagnosticArgKind : uint8
{
Signed,
Unsigned,
Floating,
Boolean,
Text,
Address
};
struct DiagnosticArg
{
DiagnosticArgKind Kind;
union
{
int64 Signed;
uint64 Unsigned;
float64 Floating;
bool Boolean;
DiagnosticText Text;
const void* Address;
} Value;
bool Truncated = false; // bounded C-string/array scan exhausted its extent
};
This view is not a general Ludus StringView: it exists to pass diagnostic
bytes without transitive STL exposure. It has no allocation, ownership, search,
encoding transformations, or generic iterators. If a native string/view arrives,
an explicit adapter can expose its data and size without changing the runtime
ABI. Existing std::string_view users can explicitly pass
DiagnosticText{view.data(), view.size()}; do not add a public STL include solely
for automatic conversion.
Provide non-template overloads for all eight fixed-width integer aliases,
float32, float64, bool, and DiagnosticText. Normalize narrow integers to
int64/uint64 and floats to float64 in tiny inline functions. usize and
isize already alias platform integer types on supported targets; do not add
duplicate overloads. Test this on LP64 and LLP64. Accept char arrays through one
small array overload, with a bounded scan for mutable arrays; literals may use
their known extent. Do not silently convert arbitrary pointers to booleans:
provide a deleted catch-all conversion template so unsupported exact matches
fail to compile. Use explicit DiagnosticAddress(const void*) and
DiagnosticCString(const char*) wrappers to distinguish addresses from text.
The latter performs a bounded scan only after Begin. Scoped enums require an
explicit underlying-width cast. No ADL formatters or arbitrary user callbacks.
The formatting adapter takes argument values by const Args&..., not through
perfect forwarding; it only reads/copies them. Bind rvalues until the call ends,
normalize scalar values immediately, and borrow text only for that synchronous
call. It packs DiagnosticArg[] and calls a non-template backend. Assert a
maximum of eight arguments with language static_assert; callers should
summarize unusually large context. Use if constexpr (sizeof...(Args) == 0) to
pass null/zero instead of creating a zero-length C array. One small template is
instantiated per argument-type tuple and literal array extent, not a parser or
formatter per type. Do not use the literal contents as a template parameter.
Representative boundary, in detail:
[[noreturn]] void FinishFatalArgs(
DiagnosticText format, const DiagnosticArg* args, usize count) noexcept;
bool FinishCheckArgs(
DiagnosticText format, const DiagnosticArg* args, usize count) noexcept;
template <usize Extent, typename... Args>
[[noreturn]] void FinishFatalFormatted(
const char (&format)[Extent], const Args&... args) noexcept
{
static_assert(sizeof...(Args) <= 8);
if constexpr (sizeof...(Args) == 0)
{
FinishFatalArgs({format, Extent}, nullptr, 0);
}
else
{
const DiagnosticArg packed[] = {MakeDiagnosticArg(args)...};
FinishFatalArgs({format, Extent}, packed, sizeof...(Args));
}
}
The internal format view includes the array terminator: the runtime checks it within the 2,048-byte bound, including nonliteral arrays. The truncation flag preserves a short unterminated array's real readable extent; a synthetic larger length must never be used to signal truncation.
The matching Check adapter returns the backend's false. Runtime format
pointers are not supported in _F; a nonliteral fixed array can still reach the
array overload, so “literal format” is an API rule, not a false claim of compile-
time enforcement. Backend validation is mandatory in all cases.
Grammar and output contract¶
The v1 grammar is exactly {} in sequence, with {{ and }} for literal braces.
No indexed/named fields, format-specifier mini-language, width, precision,
locale, UTF transcoding, or custom type formatters. Integers print decimal,
booleans true/false, addresses hexadecimal, floats bounded general notation.
Opaque handles that need hex can use an explicit future scalar wrapper if
evidence warrants it; do not grow a parser for hypothetical convenience.
The parser in diagnostic_format.cpp validates braces and argument count at
failure time. Unsupported C++ value types are compile errors; malformed format
text is not a compile error in this design. For a malformed format, emit
[format-error], a bounded copy of the original format, and indexed scalar
arguments while space remains. Preserve the original assertion kind/site and
action. Formatting errors never recursively assert, throw, or change CHECK
into fatal failure. This explicit tradeoff removes constexpr parsing at every
call site. CI tests every supported pattern; a later offline lint can check
literal field counts without charging all C++ builds.
Use fixed capacities in the runtime, initially:
- 512 bytes for the minimal pre-message header, prioritizing kind, thread, file/line, and expression in that order.
- 2,048 bytes for the complete text report, including a terminator and an
explicit
[truncated]suffix when needed. - At most 1,024 inspected bytes per borrowed C string and at most 2,048 inspected format bytes; bounded views are also clipped before reads.
- Optional 32 raw stack addresses in the crash packet; zero is a valid result.
These are implementation constants, not installed-header constants. Reserve space for truncation markers before appending; use subtraction-based bounds checks to avoid length overflow. Escape embedded NUL/control/newline bytes in borrowed arguments so one argument cannot forge report records. Source and literal data get the same output escaping where necessary. Text is treated as bytes; clipping can split UTF-8, and output consumers must tolerate it.
Use std::to_chars only in the .cpp for numeric conversion, after checking
the pinned library implementation and bounded-buffer behavior. It is a narrow,
nonthrowing conversion primitive, not a formatter: the standard specifies error
codes and no throws for these overloads. This is an explicit addition to ADR
0003's allowed implementation-only facilities, justified by avoiding a custom
floating-point conversion algorithm. See the
numeric conversion contract.
Do not infer signal safety or an allocation guarantee from that contract;
verify implementation behavior with allocation interception before using it in
the enhanced failure path. The minimal emergency header needs only a tiny
bounded unsigned decimal/hex routine and does not depend on floating conversion.
Alternatives and costs¶
| Option | Runtime and binary cost | Build cost | Complexity / disposition |
|---|---|---|---|
| Literal only everywhere | Smallest runtime and binary; poor value diagnostics | Minimal | Too restrictive for renderer/allocator investigations; retain as default tier |
| C varargs / printf formatting | Bounded output possible, but type mismatches are dangerous; %n, widths, C string assumptions need care |
Cheap headers; compiler format checking helps but is platform-specific | Conflicts with diagnostics policy as a public API; reject rather than exempt all call sites |
Existing Logging std::format |
General type safety, but current backend allocates and can fail fatally in no-exception builds | Public <format> cost already measured in this repository |
Reject for assertions, including inside failure-only branches |
{fmt} with erased arguments |
Mature and capable; careful configuration needed for allocation/error paths | Common compile-time APIs still expose formatter/checker templates | New dependency and capabilities beyond this need; reconsider only with measurements and requirements |
| Small tagged arguments + runtime parser | Fixed stack packing only on failure; one parser/converter implementation | Tiny pack expansion in opt-in header | Selected; closed grammar keeps maintenance bounded |
consteval format validation |
Can reject malformed literals early; little runtime difference on success | Parsing/evaluation per format; literal-NTTP designs can duplicate code | Defer; error fallback and tests suffice for diagnostic-only text |
| Deferred closures / async borrowed arguments | Can defer work but risks lifetimes and arbitrary user code after failure | Closure/template specialization per site | Reject; format synchronously, then copy bytes if queued |
This formatter is justified by the combined requirements of no heap, no exception reporting, a tiny assertion header, and operation during logger failure. It does not justify replacing normal Logging formatting in this change. A shared formatting facility requires its own measured proposal.
10. Failure runtime, concurrency, and reentrancy¶
Keep all coordination off the success path. In assert.cpp, use trivial
constant-initialized TLS state and one process-wide std::atomic_flag for the
primary reporting slot. atomic_flag provides the required lock-free ownership
primitive; no mutex, dynamic registration, vector, or allocator is involved.
Use acquire on successful test-and-set and release when a Check owner exits.
The packet and hook lifecycle follow that ownership; readers must use an
explicit ready/published protocol, never race with a packet writer.
Begin behavior:
| Case | Action |
|---|---|
| First fatal failure | Mark TLS active, acquire primary slot, copy site, publish minimal header; evaluate optional message afterward |
| First Check failure | Mark TLS active, acquire slot; format/report and eventually release it |
| Concurrent Check while slot occupied | Return false from Begin without evaluating its message; best-effort increment a lock-free suppression counter |
| Concurrent fatal while slot occupied | Emit its own tiny secondary header if possible, then terminate immediately; do not wait for owner |
| Recursive Check on the same thread | Return false, suppress message/hooks, preserve outer entry state |
| Recursive fatal on the same thread | Minimal recursion marker and immediate hard termination; skip formatting, logger, stack capture, and hooks |
Use an atomic counter only if the target supports it lock-free (verified at build time); otherwise omit suppression counts. A counter is optional telemetry, never correctness-critical. Every suppressed nonrecursive BeginCheck clears any TLS entry state it established and releases ownership if it acquired it before discovering budget exhaustion. A recursive Check leaves the outer TLS state untouched. This scheme deliberately trades completeness of simultaneous reports for no application-lock deadlocks. A secondary fatal can terminate before the primary has finished formatting; the first minimal header and any committed packet are the available evidence. Do not claim “all failures are logged” or wait indefinitely for the richest report.
An owner may be a job/render thread holding engine locks. No handoff to the main thread, renderer, job system, or allocator is needed. Do not stop all threads in the assertion runtime; a crash collector/debugger owns process-wide capture.
Sequential failed Checks can still flood diagnostics. V1 permits at most 64
detailed Check reports per process, counted under the primary slot; subsequent
checks return false with optional suppression counting, no formatting or
breakpoint. This simple process budget may hide a later distinct Check site;
that tradeoff is preferable initially to a per-site registry, timers, or static
atomics at every call site. Fatal failures bypass the budget. Failed Checks are
bugs to fix, not a normal high-frequency reporting channel. Tests can set a
different budget through a private test seam.
This budget suppresses report presentation only. Every enabled Check still evaluates its current condition exactly once: it returns true on success and false on failure, and the caller's recovery branch still executes on every failure. Never cache a Check result or skip a condition because its report was muted. Mark the final permitted detailed Check report with a bounded notice that later detail will be suppressed; available suppression counts belong in later collector summaries. This does not solve the acknowledged loss of distinct later sites. Do not add mutable per-site ignore flags to any assertion macro; they tax successful calls and introduce shared-state/lifetime concerns.
Conceptual owner flow:
Begin -> copy site, establish TLS/ownership, minimal fatal header
-> evaluate diagnostic values (failure branch only)
Finish -> bounded formatting into fixed report
-> commit owned crash/diagnostic packet
-> emergency byte output
-> optional bounded mirror / flush attempt
-> optional raw stack enrichment / crash notification for fatal
-> if debugger attached and policy permits: breakpoint
-> fatal: terminate; Check: clear TLS + release slot + return false
For a fatal, commit the packet before any optional consumer runs; for a Check, construct a stack-owned record consumed synchronously or copied by its adapter. Preserve header/text even when enrichment fails; set status flags for truncated text, formatting error, stack unavailable, mirror skipped, and flush incomplete. All internal validation failures degrade detail using plain branches. Do not use these assertion macros inside the lowest failure-runtime implementation.
11. Logging and emergency output¶
Required boundary¶
Do not call LUDUS_LOG_FATAL, LogSystem::ShouldLog, LogSystem::Flush,
DispatchMessage, or Logging's thread-name initializer directly from the
failure owner. They can take locks, initialize state, allocate, or reach a sink
that caused the assertion. noexcept on a wrapper cannot make them safe.
Extract the final emergency byte writer into Base behind
diagnostic_output.hpp. Logging's private EmergencyLog keeps responsibility
for its own record construction and calls this shared byte boundary. Assertions
construct their report independently. This extraction is a small, explicit
logging integration change; do not expose Logging's private sink/record headers
in the SDK. Record the shared emergency path in ADR 0003 so ordinary engine
diagnostics still go through Logging and this sanctioned failure path.
The low-level writer must not call logging, assertions, allocation, stdio, or arbitrary application hooks. Normal output failure is ignored after recording a status bit; the assertion action is unaffected. Minimal output is usable with no registered logger or hooks.
Honesty about bounded output¶
Replacing fwrite with write removes stdio's lock but does not guarantee a
nonblocking write. Pipes, terminals, files, and debugger output can block.
A timeout checked around a blocking syscall does not bound that syscall.
The Linux write contract
also requires handling partial writes and interruptions.
Use an optional preconfigured diagnostic transport with a genuinely nonblocking try-write contract (for example, a dedicated nonblocking pipe to a collector). Configuration happens during healthy startup; do not open files, change the process's shared stderr flags, or allocate a transport during failure. Cap attempts and handle partial writes/EINTR/EAGAIN without looping indefinitely. Do not assume one 2 KiB report is atomic on every pipe/OS. Serialize primary output with the reporting slot; secondary emergency messages may interleave.
For the initial Linux bring-up, direct stderr output is a best-effort fallback with an explicitly documented blocking limitation. A production shipping port claiming bounded fatal completion must use the nonblocking transport or skip unsafe external writes and rely on its committed crash packet/OS crash capture. When no channel works, termination still occurs. Immediate visibility is attempted before break/termination; guaranteed visibility in every corrupted environment is not a satisfiable contract.
Optional logging bridge¶
The rare diagnostic_hooks.hpp API describes an immutable, versioned hook table
with a context pointer and two operations:
enum class DiagnosticDelivery : uint8 { Accepted, Busy, Unavailable };
enum class DiagnosticFlush : uint8 { Complete, TimedOut, Unsupported };
struct DiagnosticRecordView
{
FailureKind Kind;
AssertionSite Site;
uint64 NativeThreadId;
DiagnosticText Text;
};
// Representative callback signatures, not functions registered per assertion.
using TryPublishDiagnostic = DiagnosticDelivery (*)(
void* context, const DiagnosticRecordView& record, uint64& ticket) noexcept;
using TryFlushDiagnostic = DiagnosticFlush (*)(
void* context, uint64 ticket, uint64 monotonicDeadlineNs) noexcept;
The view contains kind, copied site fields, native thread ID, and pointer/length text. A successful publish must synchronously copy all needed bytes into bounded owned storage; it may not retain argument arrays, caller stack buffers, or site pointers into an unloadable module. Both operations must have no unbounded locks, allocation, logger recursion, user callbacks, or blocking I/O on the failure thread. Deadline units are native monotonic nanoseconds, not wall clock. “TimedOut” cannot be implemented by timing out a thread still writing borrowed memory; no detached worker may retain the view.
The current synchronous logger does not meet this contract. Initially install
no normal-logger hook, share the hardened emergency writer, and keep the crash
packet. Do not label the current Flush() a best-effort bounded flush. This is
a real limitation of current logging, not a hidden assumption in the design.
When async logging lands, implement the bridge through its preallocated bounded
queue and a diagnostic bypass lane if the normal queue is full. A failed
assertion bypasses category/severity filtering. Publish returns a ticket ordering
the report after previously accepted log records; flush waits for that ticket's
sink completion up to 50 ms total across optional consumers. It must detect
the logging worker and logger/sink reentrancy and return Unsupported immediately
there. A full diagnostic lane returns Busy; emergency output remains primary.
No assertion thread steals the logger's queue-consumer role or drains sinks
while holding unrelated engine locks. Flush is bounded best effort, never a
prerequisite for termination. A hung sink prevents acknowledgment but not
assertion termination.
Install the table before starting workers, with process-lifetime table/context
storage. No concurrent replacement, unregistration, hot unload, or mutable hook
registry in v1. A logger shutdown sets the adapter's own availability state;
its storage remains valid, and it returns Unavailable after shutdown. The
default is a null table. Tests inject through a private seam before spawning
their process/threads. This keeps lifetime management out of every report.
12. Debugger and platform behavior¶
Private backend contract:
enum class DebuggerState : uint8 { Attached, Detached, Unknown };
DebuggerState QueryDebugger() noexcept;
void BreakForDebugger() noexcept; // deliberately may return on debugger resume
[[noreturn]] void TerminateForAssertion() noexcept;
[[noreturn]] void TerminateImmediately() noexcept;
Query only after failure; never poll on success or cache debugger presence at
engine startup. Attach/detach is possible later. Unknown behaves as detached.
Detection cannot atomically synchronize with debugger detachment; a breakpoint
may become a fatal OS exception/signal in that race. No dialog or wait-for-attach
loop is part of the engine API.
| Platform | Detection | Inspection breakpoint | Fatal termination |
|---|---|---|---|
| Linux / Clang 18 reference | Bounded read/parse of /proc/self/status TracerPid; inaccessible/truncated information gives Unknown |
Guarded __builtin_debugtrap() |
abort() after report/crash notification; immediate _Exit path for recursive/compromised reporting |
| Windows port | IsDebuggerPresent() in private OS TU |
__debugbreak() |
RaiseFailFastException, with immediate process termination fallback if unexpectedly returned |
| macOS port | Private debugger-query adapter using supported SDK facilities; Unknown until validated | Clang debugtrap when supported | POSIX abort/immediate-exit backend, validated with crash collector |
| Future console | Vendor-supported debugger/crash APIs in private adapter | Vendor break | Vendor crash primitive |
Linux exposes tracer identity via
TracerPid;
it indicates tracing, which need not be an interactive debugger. A trace-aware
CI configuration should suppress inspection breaks through startup diagnostics
policy, without weakening fatal termination. Windows provides explicit
debugger detection
and the breakpoint intrinsic.
Do not substitute __builtin_trap() for a resumable inspection breakpoint.
Use it only as an abnormal-termination primitive in a validated backend.
Do not assume x86 int3 encoding or use inline assembly in public headers.
Break inside a noinline cold runtime function; the debugger can inspect the
stored site and walk to the caller. Keeping the breakpoint literally at each
source expansion adds platform machinery/code per site. That cost does not make
debugger usability optional: verify that developers can identify the original
assertion line and select its caller frame, including available locals, in Debug
and optimized Development builds. Test optimized stack quality and disable
sibling-call optimization in the small runtime if needed; debugger skip/step
annotations are preferable to duplicating breakpoint policy in macros. If the
reference debugger cannot provide usable caller context, reconsider breakpoint
placement with measured code/header costs. Source metadata remains authoritative
when optimization removes frames or locals; do not promise full local-variable
recovery in optimized code.
Without a debugger: report and terminate for REQUIRE/FATAL failures; report
and return false for Checks. There is no unconditional trap for a recoverable
Check. Continuing from an inspection breakpoint in a REQUIRE/FATAL report
proceeds to termination, never to the invalid operation.
The one exception is an enabled ASSERT/ASSERT_F under the interactive-
development policy (§5.1,
ADR 0006): in a local,
non-CI Debug build, continuing past the inspection breakpoint of a failed
ASSERT returns to the caller instead of terminating, and when no debugger is
attached a separate Continue-once / Terminate prompt offers the same choice.
This requires two additional private, detached backend capabilities beyond the
table below — a CI-environment query and an interactive Continue-once /
Terminate prompt — both Debug-only, both on the failure path only, and both
degrading to Terminate when unavailable (headless, no terminal, absent helper).
They must not poll on success, must not be a public-header dependency, and must
sit at the same private OS boundary as QueryDebugger/BreakForDebugger.
BreakForDebugger already "deliberately may return on debugger resume"; §5.1
defines what returning means per kind. For REQUIRE/FATAL, and for ASSERT
outside the eligible Debug scope, a returned break still falls through to
termination.
Linux/Clang remains the reference platform. macOS now has the native sysctl query/debugtrap/termination adapter and helper transport described in the diagnostic launch guide, with native startup and explicit-decision regression coverage. The historical Windows row remains a port contract, not a claim of tested support. Unsupported backend selection must fail configuration; silent no-op fatal stubs are forbidden.
13. Crash packets, stack traces, and termination¶
Reserve one static primary fatal packet with a format version, build ID, failure kind, native thread ID, bounded owned text/site strings, and optional raw PCs. Copy the data; do not retain caller-owned message pointers. Publish completeness through lock-free state with acquire/release ordering. A crash reader may use only the last committed stage; it must not read a partially written buffer. Use three separately published, immutable regions: minimal site/header, complete report, then optional stack/delivery-status enrichment. Each region is written once and published with a release store; readers check readiness with acquire loads before touching that region. The fatal owner never releases the slot or reuses packet storage. Checks use stack records and never publish into this packet, avoiding reuse races with a concurrent crash reader. A secondary fatal does not overwrite it. This is a single terminal incident, not a reusable lock-free history buffer. Core/minidump readers outside the live process must also validate region readiness/version rather than infer completeness.
Initially, capture no in-process backtrace unless a backend is proven bounded
and allocation-free. Preserve the site plus the native abort/fail-fast crash
context; the debugger/core/minidump captures the failing thread's stack. The
enhancement hook may fill up to 32 PCs, with zero indicating unavailable.
Never call std::stacktrace, symbolizers, loader enumeration, or general
backtrace_symbols from this path. Native unwinding can allocate or lock even
when its output buffer is supplied; validate each implementation rather than
assuming safety. Do not walk arbitrary frame-pointer chains without bounds.
Build ID, retained symbols/unwind information, and module mappings collected at healthy startup enable offline symbolication and ASLR relocation. Frame pointers improve some capture paths but impose whole-program runtime cost; make their use a measured platform build policy, not a hidden assertion flag. Symbol files and source mapping must be archived per shipping build.
Report handoff without a debugger¶
The complete text report must be copyable as one bounded plain-text block. Include report-format version, build ID and build flavor, assertion policy, failure kind, native thread ID, repository-relative file/line, function, expression (or an explicit unconditional marker), message, and capture/truncation status. Required identity fields precede optional message detail so a long value cannot displace the build or site. No address-only or message-only report counts as useful capture. Preserve the expression even when a descriptive message is provided.
Generate that block once from the report's owned fields using the existing
bounded writer; emergency output and optional consumers reuse its bytes. These
fields do not introduce formatting templates or includes at assertion sites.
Later delivery/stack status belongs to the separately committed enrichment
region; a collector may append it without mutating the original report. Keep
explicit unavailable and truncated indicators, including when stack capture
was not attempted. Tools can group incidents by the tuple of build ID, relative
file, line, and expression; v1 needs no runtime hashing or persistent site registry.
A launcher, external crash collector, or healthy editor tool may save, display, and copy an owned report to a bug tracker. Clipboard access, dialogs, file pickers, and uploads do not belong on the failing thread or in Base. UI failure must not change termination/recovery behavior. The core runtime does not need to implement that UI to provide a useful artifact.
For a QA deployment, verify end to end that a failure with no debugger leaves a retrievable report associated with the exact executable/symbols. A stderr line that disappears when a graphical application closes does not satisfy this operational acceptance test. The deployment must configure a collector, retained launcher output, or suitable OS crash artifacts; the core's best-effort transport alone cannot guarantee persistence. Caller stacks are particularly valuable for shared low-level helpers: test symbolication through a caller/helper chain when crash capture is configured. If the deployment has no safe stack capture, state that limitation visibly instead of attempting unsafe symbolication during failure.
A future crash collector may register one process-lifetime, nonblocking notification callback in the rare hooks table. It receives the committed packet and copies/notifies using preallocated storage/IPC. It must not upload, symbolize, walk all threads, allocate, or block in this call. Call it before final termination; if Windows fail-fast bypasses the reporter's exception handlers, this explicit notification is the integration point. External collection/upload runs later.
abort() gives a conventional POSIX abnormal termination signal, can produce
a core depending on host policy, and does not run normal C++ destruction or
atexit shutdown. Do not depend on it to flush stdio/logging; modern glibc does
not flush streams in abort. See abort behavior.
A returning SIGABRT handler does not turn the assertion into a recoverable path,
but a stuck application signal handler can still stall normal abort. The
recursive/compromised path uses immediate exit and accepts losing a core. A
shipping port needing a stronger fail-fast guarantee must validate its native
termination/watchdog contract; no C++ declaration can guarantee progress under
an arbitrary hung handler/kernel.
Windows fail-fast is an OS exception mechanism, not a C++ throw; it bypasses
frame/vector exception handlers and can invoke WER. That distinction matters
to reporter integration. See
RaiseFailFastException.
Do not call exit, logger shutdown, destructors, job joins, GPU idle waits, or
allocator cleanup on a fatal path. The state that makes cleanup safe may already
be broken. General assertions are not async-signal-safe: actual SIGSEGV/stack
overflow handlers need a separate minimal backend, possibly an alternate stack,
that only references committed data and uses approved OS operations.
14. Runtime and build-time performance contracts¶
Passing code¶
At an optimized plain site, expect the equivalent of:
compare/test condition
conditional jump to cold failure block
continue ordinary code
cold failure block:
materialize site arguments
call BeginFatal
call FinishFatal
There is no success-path global/TLS read, atomic operation, static initialization,
clock, debugger query, message evaluation, or function call introduced by the
assertion system. A condition itself can of course be expensive or perform a
call. [[unlikely]] is a compiler layout hint, not a hardware predictor guarantee.
Keep failure functions cold/noinline where supported and verify with LTO as well
as ordinary optimized builds. Do not force-inline the formatter/handler.
The compiler can fold known-true enabled conditions. REQUIRE retains a real
guard when needed; its nonreturning failure arm may legitimately allow
optimization after the guard. Disabled ASSERT must never become assume or
unreachable: that would introduce undefined behavior instead of removing a
diagnostic. Both _F and base forms evaluate no message arguments on success.
Failure-only argument packing can still influence stack-frame layout at -O0
or under some optimizers; do not promise identical stack frames or zero code
size. Inspect optimized output, especially large argument arrays.
Header and template budget¶
assert.hpp: target below 200 non-comment lines; no function templates and no STL includes beyondtypes.h's two existing alias headers.assert_format.hpp: target below 300 non-comment lines; fixed scalar overloads, one pack adapter per return category, and only the few array/ rejection helpers needed for safety. No recursive template metaprogramming.- Each enabled plain site adds a branch, metadata references, and cold calls.
It does not instantiate a handler template. Each
_Fsite may instantiate a small pack adapter; type erasure prevents per-site parser/converter code. - Every disabled site disappears at preprocessing expansion. A TU that includes the format header still pays its small parse cost even with assertions off.
- Format-parser changes rebuild one implementation TU and relink. Handler or OS-backend changes do likewise. Changes to hook structures rebuild only rare integration consumers. A hot-header edit necessarily invalidates its users.
- No PCH, module, unity build, or linker folding is required for acceptable performance. Such tools can improve measurements but cannot excuse a heavy public API.
Measurement gates, before implementation is accepted¶
Use pinned Clang 18, fixed host/load/options, cache disabled, and record medians
of five runs plus variance. Create synthetic TUs with 0, 100, 1,000, and 10,000
sites: plain, literal-message, repeated formatted type packs, and diverse packs.
Include no-site/header-only baselines and the current logging header as a
comparison. Measure frontend parse/instantiation time with -ftime-trace, peak
compiler RSS, backend time, object .text/.rodata, and linked binary sections.
The comparison is not a claim that logging and assertions have identical scope.
Initial acceptance targets (proposed budgets, not measured results):
- An empty TU including
assert.hppadds at most 10 ms median frontend time on the reference host; if run noise exceeds that, use aggregate many-TU measurements and report uncertainty instead of inventing precision. - At 1,000 plain enabled sites, frontend time stays within 15% of equivalent handwritten condition/metadata/cold-call code. At 1,000 formatted sites, stay within 25% of equivalent handwritten tagged-array code.
- No public-header
<format>,<filesystem>,<chrono>,<atomic>, or OS includes; no parser/converter instantiations in consumer trace reports. - Disabled sites leave no report call or unique diagnostic string in an optimized object. Passing optimized sites contain no runtime coordination.
- Repeated-type formatter text growth must track only packing/call-site work, not copies of the parser; measure diverse type packs separately.
- Touching only
diagnostic_format.cpprebuilds that object and relinks without recompiling consumers. Testassert.hppand format-header touches separately.
Use the existing scripts/profile-build for an engine-wide before/after result;
it deletes its selected build tree, so use a dedicated profiling preset/tree.
If budgets fail, simplify the adapter/header before introducing PCH or replacing
the formatter. Record actual numbers in a follow-up decision; this document
makes no unmeasured “zero build cost” claim.
15. Standard-library dependency decisions¶
| Facility | Location | Runtime / binary | Parse / portability / API rationale |
|---|---|---|---|
<cstddef>, <cstdint> |
Existing types.h only |
Aliases, no runtime work | Established portable widths; do not duplicate them |
<atomic> |
Runtime .cpp only |
Failure-only flag/counter operations; no allocation | Standard correctness instead of custom compiler atomics; require lock-free properties where needed |
<charconv> / to_chars |
Formatter .cpp only |
Bounded conversion/error return; float code can add binary weight, measure it | Compile once; avoids inventing numerical conversion; audit library behavior |
<cstdlib> or platform equivalent |
Termination .cpp only |
One fatal operation | Well-defined normal abort/immediate-exit entry; OS-specific stronger path where needed |
<string_view> |
Existing logger/bridge internals, if already needed | Nonowning; no allocation | Acceptable in principle, but no reason to expose it in this new narrow assertion ABI |
<source_location> |
Existing Logging only | Usually metadata pointers, implementation-defined layout | Not inherently heavy; rejected here because the required macro already captures a simpler explicit record |
Containers, strings, smart pointers, <format>, <sstream>, <iostream> |
None in assertion subsystem | Avoid allocation/uncontrolled failure/binary machinery | Unnecessary API surface and include cost |
This is selective standard-library use, not a ban on the library. Atomics and numeric conversion solve concrete correctness problems; strings, generic formatters, and handler containers do not improve this subsystem enough to justify their costs. A closed diagnostic tagged union is materially smaller than inventing an engine-wide type-erasure or variant abstraction.
16. Failure modes and edge cases¶
| Case | Required behavior |
|---|---|
| Pre-init, post-shutdown, static destructor | Base runtime requires no logger lifetime; emergency report still attempted |
| Assertion inside logger/sink/allocator | No normal logger call; use independent runtime and emergency transport |
| Message argument asserts recursively | Begin has already guarded the site; recursive fatal exits, recursive Check returns false |
Resumable ASSERT continued (Debug, local, non-CI, explicit action) |
Return to caller after committing the report; release slot/clear TLS like a Check; invariant now known broken (§5.1) |
ASSERT failure while headless / no terminal / helper absent |
No prompt possible; outcome is Terminate, never default-continue |
ASSERT failure detected under CI (even a local Debug binary) |
Runtime CI veto: no prompt, no resume-to-caller; terminal fatal path |
Recursive/contended ASSERT in resumable build |
Never resumable; immediate secondary/recursive termination with no prompt |
| Message argument dereferences corrupt pointer | May fault; minimal fatal header already committed; no portable C++ recovery promised |
Null DiagnosticCString / null base message |
Explicit null marker for wrapped string; omitted message for base null |
| Invalid non-null text pointer | Caller contract violation; bounded length does not validate memory mapping |
DiagnosticText{nullptr, nonzero} |
Render [invalid-text] without dereference |
| Huge length/unterminated string | Cap reads/output; safe arithmetic; explicit truncation |
| Unsupported formatting type | Compile error; explicit scalar/view conversion required |
| Format error / output exhaustion | Retain primary site/action, print bounded marker/fallback |
| Output unavailable, partial write, disk full | Stop bounded attempt, set status; never recursively report output errors |
| Full logger queue / hung sink | Drop mirror or timeout; emergency/crash packet precedes it |
| Debugger detach race | May terminate on breakpoint signal; no continuation guarantee |
| Signals or stack exhaustion | Use separate OS crash entry, not normal macros |
| Floating NaN/Inf/negative zero | Stable bounded formatter output; test chosen library representation |
| Fatal in a destructor or lock scope | Terminate without unwinding/cleanup; do not unlock application state |
| Dynamic module unload | Hooks forbidden to unload; queued records own text/site bytes |
| Multiple Base runtimes in DLLs | Unsupported; fix linkage to one runtime before enabling plugins |
| Assertion expression calls expensive code | Caller owns that runtime cost; only ASSERT may compile it away |
| Side-effectful diagnostic expression | Unsupported usage even though evaluated at most once; may be skipped |
| Release-only unused variables | Use variables for real work or [[maybe_unused]]; do not retain disabled condition evaluation just to silence warnings |
Null UniquePtr destruction |
Valid C++ ownership behavior; remove the existing TODO suggesting it should assert/log, do not implement that TODO |
17. Validation strategy¶
Tests must check behavior and failure isolation, not merely duplicate the macro
implementation. Use Catch2 where appropriate, but execute fatal paths in child
processes. Never implement fatal testing by throwing through a production
noexcept boundary or by replacing a [[noreturn]] function with a returning one.
- Evaluation and preprocessing: Debug/Development/Profile/Release fixtures
prove zero/one condition evaluations, message evaluation only on failure,
no evaluation of suppressed reports, explicit-bool conversion, dangling-else
safety, nested macro stringification, comma expressions, no-message variants,
_Fwith zero/eight/nine arguments, and constexpr passing/failing cases. Disabled fixtures may intentionally reference an undeclared condition/message identifier to prove arguments are not semantically compiled. - Type/format safety: Compile-fail fixtures for unsupported types/pointers and over-limit packs. Test every scalar width, signed minimum, maximum unsigned, LP64/LLP64 overloads, null strings, embedded control bytes, brace escapes, malformed/mismatched formats, long data, and buffer boundary cases. Fuzz the non-template parser with valid bounded memory under ASan/UBSan.
- Allocation: Intercept allocation in isolated binaries; first-ever failure, formatted failure, pre-init and post-shutdown must not allocate in Base's minimal/enhanced approved path. Test float conversion separately. No-exception compilation is mandatory for the production library even though tests use Catch2.
- Fatal behavior: Child-process tests verify nonzero abnormal termination, expected primary diagnostics, no normal destructor/atexit cleanup, and actual native signal/exception behavior. Redirect to a bounded known-good test transport; do not accidentally block on a full test pipe.
- Debugger policy: Private backend fakes record query/break/terminate order and ensure resuming a breakpoint still terminates. Real LLDB/GDB/Windows debugger smoke tests validate stopped stack/site and detached behavior. Fake backends do not replace native death tests.
- Concurrency: Barrier-start simultaneous failures, recursive callbacks, failures from logger worker/sink and while application locks are held; check suppression behavior, packet publication, no owner wait, and bounded exit. Use subprocess watchdogs and TSan where supported. Check budget exhaustion must not suppress a later fatal. After exhaustion, exercise changing Check conditions and prove that every false result still enters recovery and every true condition still returns true; verify the suppression notice.
- Transport and integration: Full queues, unavailable hooks, shutdown races within the supported fixed-lifetime contract, partial/interrupted writes, nonblocking EAGAIN, dropped mirrors, and unsupported/timed-out flushes. Verify a retained record owns its bytes after a Check returns.
- Binary/build/SDK: Execute the measurement gates above, inspect assembly and strings, run an installed consumer for each assertion policy, ensure private platform/sink headers are not exported, and verify no dependency cycle.
- QA handoff: Trigger an invariant in a shared helper from two distinct callers, with no debugger attached. Retrieve the bounded report, match its build/flavor/site to archived symbols, and inspect a caller stack where the deployment supports it. Verify text remains usable after copy/save, missing stack/truncation is explicit, and collector/UI failure cannot block termination.
For implementation, run the repository's warning-clean build/unit checks, format/tidy with pinned version 18, ASan/UBSan, and SDK install/consumer checks. Add Release-policy test executables even though the current Release preset normally disables unit tests; configuration-matrix compile/death tests cannot be skipped merely because production tests are off. Document debugger and platform manual checks separately from automated results.
18. Implementation and migration sequence¶
- Record the policy. Accept this design or a focused ADR. Explicitly amend ADR 0003 for implementation-only numeric conversion and the shared emergency boundary. Resolve Profile flavor/config export and the small compiler helper split first. Do not refactor unrelated Base utilities.
- Deliver the smallest vertical slice. Add
assert.hpp, generated policy, Linux failure runtime and emergency output; plainASSERT,REQUIRE,CHECK,FATAL; death/evaluation tests; install the public headers. It works without logging or crash hooks. Keep the two-stage guard from the first version. - Harden failure handling. TLS recursion guard, atomic ownership, bounded packet stages, Check suppression budget, debugger tests, and the private nonblocking transport contract. Share the emergency writer with Logging. Explicitly record whether a deployment still uses blocking stderr fallback.
- Add opt-in formatting. Closed tagged arguments, tiny pack adapter,
parser/converters in
.cpp, compile-fail and fuzz tests, allocation audit. Run compile/binary benchmarks before spreading formatted call sites. - Add narrowly scoped call sites. Start with actual ownership, bounds, and
state invariants. Keep ordinary window/file/device errors as return values.
Audit required side effects and recovery branches. Remove the misleading
null-deletion TODO in
pointer.hpp; do not assert on a valid null deletion. - Integrate richer diagnostics when available. Immutable hook table,
validated crash collector, symbols/build IDs, and optional raw-PC capture.
Add logger publish/flush only when its implementation meets the stated
nonblocking lifetime contract; current synchronous
Flushdoes not. - Port and measure. Windows/macOS/console implementations require native failure and debugger tests before support is advertised. Maintain compile cost/binary-size baselines and one runtime across eventual shared modules.
An implementation is not production-complete merely because the macros compile. Its target deployment must pass fatal/reentrancy tests, demonstrate allocation and build-cost properties, and honestly state its transport/crash-capture guarantees. Rich logging and symbolication are optional; safe fatal control flow and independent minimal diagnostics are not.
19. Decision rationale and complexity budget¶
| Decision | Problem solved | Realistic alternative | Runtime/build cost | Complexity and fit |
|---|---|---|---|---|
| Runtime inside Base | Base/allocator/Logging must assert without upward dependencies | New Diagnostics target below everything | Only link runtime when referenced; no added target graph layer | A few OS TUs fit current small engine; split later only for real layering pressure |
| Fatal invariants, separate Check | Continuing through broken assumptions is unsafe | Ignore/continue dialogs or universal boolean assertions | Fatal cold path; Check branch in all builds | Explicit control flow with no runtime decision framework |
Four names plus _F |
Balance usable diagnostics and hot-header cost | One overloaded all-purpose macro | Plain sites template-free; formatting opt-in | Slight naming cost buys a clear include/build boundary |
| Manual site aggregate | Expression macros already required | std::source_location |
No extra include; metadata strings still cost bytes | Transparent and adequate; not ideological rejection of a small STL facility |
| Closed tagged formatter | Useful values without allocation/heavy headers | printf, std::format, fmt, literals only | Failure-only packs; one runtime parser; float binary cost measured | Small grammar avoids becoming a formatting framework |
| Runtime grammar validation | Avoid per-call compile-time parsing | consteval checks | Tiny failure-path validation; cheaper TU processing | Explicitly weaker literal diagnostics, compensated by deterministic fallback/tests |
| Begin before message evaluation | Recursive/faulting diagnostic expressions | Single reporting function | One extra cold call; small TLS only on failure | Worth the narrow two-stage internal protocol; no generic scopes/closures |
| Single nonwaiting report slot | Concurrent failures and locked worker threads | Global mutex / wait for first handler | Failure-only atomic operation; secondary reports may be lost | Predictable failure rather than synchronization complexity |
| Independent emergency channel | Logging itself may fail or be unavailable | Always log-and-flush | Bounded text copy/output attempt; no logging header | Required safety boundary; shared final writer limits duplication |
| Null initial logging hooks | Existing logger cannot satisfy bounded flush | Wrap current Flush with a timer | No unsafe call; fewer persistent log records on fatal | Honest integration gap, resolved with future async backend rather than fake safety |
| Optional committed crash packet | Preserve evidence without safe in-process unwinding | Symbolicate on failing thread | Fixed static storage; failure-only copies | Separates evidence collection from expensive crash processing |
| Explicit generated policy | Profile mismatch, NDEBUG ambiguity, installed SDK drift | Header fallbacks / per-TU defines | Policy changes rebuild consumers, intentionally | One authoritative variant contract prevents silent ODR/configuration mistakes |
The recommendation keeps the always-included surface almost entirely language syntax and declarations. Its custom pieces have specific engine requirements: bounded diagnostic text, independent failure handling, and a controlled ABI. It reuses standard atomics and numerical conversion privately where doing so is safer and cheaper than inventing equivalents. The implementation remains small because it refuses resumable fatal invariants, arbitrary formatters, handler registries, and promises that the current logger cannot fulfill.
20. Validation performed for this design¶
The representative site/base-macro declarations were extracted into a temporary
standalone probe and compiled with Clang 18, C++23, -fno-exceptions, and
warnings-as-errors. Enabled and disabled probes passed condition/message
evaluation, suppressed Check messages, explicit-bool conversion, constexpr
success, dangling-else, and disabled undeclared-argument checks. Document-local
links, code fences, and whitespace were checked. These probes validate the
proposed macro boundary only; the runtime, formatter, crash transports, platform
ports, and performance budgets remain implementation work. No full engine build
or sanitizer run was performed for this documentation-only change.
The subsequent GPG1 review visually inspected the complete chapter (printed pages 109-114; PDF pages 107-112). Its findings and a small modern-C++ probe are recorded in the review. No historical sample was adopted as engine implementation.