aether is a C++20 static library for persistent state, asynchronous actions
and tasks, transport, streams, protocol APIs, cloud/server connections,
cryptography, and platform adapters.
Persistent objects represent durable identity, configuration, and state. Runtime objects perform transient asynchronous work, connection management, stream processing, and transport operations. Keeping this distinction prevents runtime state from entering the persisted object graph and prevents durable objects from being managed as temporary operations.
Actions, streams, and connection/transport logic are runtime objects, not
persistent Obj types.
- Persistent entities derive from
ae::Obj, useAE_OBJECTand reflection, and implement the establishedLoad/Savepatterns. - Use
ae::ObjPtr<T>for strong references to persistent objects. - Use
ae::Ptr<T>for shared ownership of non-Objobjects. ae::Ptr<T>uses reachability counting on the reference graph to reclaim cyclic references. Releasing a pointer may therefore be expensive; avoid unnecessary copies, pass it by reference when possible, and move it when transferring ownership.ae::PtrView<T>is a weak, nullable view. Lock/load it before retaining or dereferencing the object.- A valid
ObjPtrmay still refer to an unloaded object. Load it before use and retain the loaded pointer while using it.
ae::AeContext is a non-owning view of Aether and its task scheduler. Runtime
components use ae_context.scheduler() to schedule work. The context does not
extend the lifetime of Aether, the scheduler, or objects captured by callbacks.
Accept a context or operation inputs in a constructor only when the operation
requires them. Follow the surrounding component pattern for constructor-started
work versus an explicit Start() method.
Event owns its handlers. EventSubscriber is a non-owning façade used to
subscribe and emit through an owning object.
Subscribe() returns RegEventHandler. It is lightweight and is not an
RAII object: destroying or discarding it does not unsubscribe the handler.
RegEventHandler remains a copyable aggregate, but every returned token must
be transferred to exactly one Subscription or MultiSubscription; copying or
retaining it for multiple owners is unsupported misuse. Use Subscription to
control the handler lifetime; do not manage the returned deleter directly.
Use RAII lifetime control when a callback should have an owner lifetime:
Subscriptionowns oneRegEventHandler; destruction/reset unsubscribes.MultiSubscriptionowns several deleters and unsubscribes them together.- A temporary
Subscriptionunsubscribes at the end of its scope. - Retain the subscription for as long as its callback may run.
Unregistering an event rejects new invocations and registrations. Handlers already captured by an active invocation snapshot continue unless individually unsubscribed.
Define events with a private Event<void(...)> member and expose a subscriber:
using ChangedEvent = Event<void(Value const&)>;
ChangedEvent const& changed_event();
private:
ChangedEvent changed_event_;Return changed_event_ const& from the accessor and emit from the
owner with changed_event_.Emit(value).
Action is a move-only runtime operation with completion state and
finished_event(). Define a concrete action by deriving from Action, adding
only the context, inputs, events, and subscriptions it needs, and following the
existing constructor or Start() pattern.
Expose typed result or progress events where callers need them. Always emit the
terminal result before calling Finish():
result_event_.Emit(result);
Finish();Finish() marks the action finished and emits finished_event(). An action may
be deleted after Finish(), so do not access it or perform action-dependent work
after that call.
The action owner keeps the action alive until action.is_finished() is true.
Share an action by reference when it is guaranteed to exist, or by raw pointer
when it may be absent. Shared action access is non-owning: do not use smart
pointers or wrapper types to share actions with callers.
ActionPool provides fixed-capacity storage for actions that must survive the
initiating call. Create() returns a non-owning raw pointer and may return
nullptr when capacity is exhausted. The pool observes finished_event() and
schedules destruction.
ActionsQueue sequences operations. It runs stages in
FIFO order, starts the next stage after the current action finishes, observes
finished_event(), supports stopping the current action when it provides
Stop(), and allows stages to be added dynamically.
ManualTaskScheduler is driven by the application loop: call Update(), then
WaitUntil() with the returned wake-up time. Tasks must be lightweight and
non-blocking; use delayed tasks for time-based work.
TaskSubscription is move-only RAII control for a task. Retain it while its
callback may run. Resetting or destroying it cancels the task; a temporary
subscription therefore cancels work immediately. After execution, the task
invalidates its subscription.
Task storage has static capacity shared by the task queues. Allocation or queue
exhaustion is an exceptional fixed-resource failure, generally not recoverable.
Code may check the returned subscription when that distinction matters; if the
failure is detected, log it and use assert(false && "Task allocation failed").
Include aether/executors/executors.h for the stdexec and project executor API.
Use SchedulerOnTasks to run sender work on the task scheduler, compose work
with the provided senders and adapters, and complete it through AsyncWaiter,
SyncWaiter, or AnyWaiter as appropriate. Use WithTimeout for bounded
operations. The first completion wins; retain the waiter, operation, and
captured state until completion or timeout, and handle timeout separately from
ordinary errors.
-
API protocol and server APIs operate over runtime streams.
-
API classes derive from
ApiClassand receive aProtocolContext. -
Client-side API methods are data members of type
Method<MessageId, Signature>. Usevoid(Args...)for fire-and-forget methods andApiPromise<Result>(Args...)for methods that return a value or an error. -
A return-value method generates a request ID, sends the packed request, and returns
ApiPromise<Result>; callers must use the normal promise/sender/ waiter path to observe its result or error. -
Define an API class with explicit method IDs and signatures, then initialize its methods with the class
ProtocolContext. Keep message IDs stable and unique within the API. -
For server-side dispatch, derive from
ApiClassImpl<ConcreteApi>, implement methods with matching signatures, and register them withAE_METHODS:class ExampleApi : public ApiClassImpl<ExampleApi> { public: explicit ExampleApi(ProtocolContext& protocol_context); void Handle(DataBuffer data); AE_METHODS(RegMethod<3, &ExampleApi::Handle>); };
-
Use
SubApi<T>and the existing API context/parser patterns for nested API calls instead of inventing a separate packet format.
Serialization is provided by the aether-miscpp dependency. It is used both
to save and load persistent Obj state and to encode and decode API protocol
messages.
There are three ways to make a type serializable:
- Use the project's reflection support when the type is a straightforward aggregate of serializable members.
- Provide a
seri::Serializer<Archive, Type>specialization. Prefer this approach because it keeps serialization logic separate from the model type. - Add
Seri()andDeseri()member functions when serialization intrinsically belongs to the type or a member serializer is otherwise the best fit.
Implement a serializer against the general seri::Archive concept when the
representation is independent of the underlying archive. Specialize it for a
specific archive when the representation depends on that archive's storage or
wire format. The currently available concrete archive is
seri::BinaryArchive<BinaryBuffer>.
A serializer provides Seri() for saving and Deseri() for loading. Saving
receives Meta<T const> and loading receives Meta<T>; both return
SeriResult:
namespace ae::seri {
template <Archive A>
struct Serializer<A, MyType> {
SeriResult Seri(A& archive, Meta<MyType const> meta) const {
return archive.Save(Meta{meta.value.member});
}
SeriResult Deseri(A& archive, Meta<MyType> meta) const {
return archive.Load(Meta{meta.value.member});
}
};
} // namespace ae::seriBinaryBuffer exposes two pairs of Read and Write operations. The size
operation represents a container size, meaning a count of elements. The data
and size operation represents a payload together with its size, for one or
more elements. Different buffer implementations may use different physical
representations for the size and data, so serializers should use the buffer
operations rather than assuming a particular layout.
Reflection is provided by the aether-miscpp dependency. It describes the
members of a type so generic code can inspect or process them, including
serialization and other algorithms.
For regular members, declare the reflected members in the type with:
AE_REFLECT_MEMBERS(a, b, c)For explicit reflection entries, use AE_REFLECT and pass it reflection
entries. Use AE_MMBR(member) for one regular member or
AE_MMBRS(first, second) for multiple regular members. Use AE_REF(member)
when a reflected member is a reference. For example, use
AE_REFLECT(AE_REF(member)) for a single explicit reference member. For base
classes, AE_REFLECT supports both AE_REF_BASE(Base) and AE_BASE(Base):
- Use
AE_REF_BASE(Base)to reflect a reference toBaseas one member. - Use
AE_BASE(Base)to concatenateBase's reflected members into the derived type's reflection.
All explicit reflection helpers can be combined in one declaration:
AE_REFLECT(AE_MMBR(member), AE_MMBRS(first, second), AE_REF(reference),
AE_REF_BASE(BaseAsMember), AE_BASE(BaseMembers));Create a reflection object with ae::make_reflection(obj) and apply a
callable to all reflected members with Apply():
auto reflection = ae::make_reflection(obj);
reflection.Apply([](auto&&... members) {
// Process the reflected members.
});- Streams publish state and data through events; writes return actions.
- Use
stream_info()instead of assuming writability, reliability, link state, or supported element sizes. - Keep linked stream objects alive while links and subscriptions are active; unlink them before destruction.
- Cloud connections coordinate server connections and connection policies.
- Server connections manage channels and failover. Determine health from connection/stream state, not object existence alone.
- Subscribe to asynchronous result/error events before starting an operation and handle both request failures and result-level errors.
Tele is the public telemetry facility from the aether-tele dependency,
configured through aether/tele.h.
- Use regular logs such as
AE_TELED_DEBUG,AE_TELED_INFO, andAE_TELED_ERROR. - Register a module tag when tagged logging is needed.
- Use registered tags with
AE_TELE_<LEVEL>(kTag, ...).
Format is provided by the aether-miscpp dependency and can be used on its
own to build formatted strings or to provide a format string to a telemetry
log, for example:
AE_TELE_DEBUG(kTag, "Format string {}", data);-
Use
{}for replacement fields. Arguments are consumed from left to right; for example,Format("id={}, state={}", id, state). Formatting schemes can be selected after a colon, such as{:time}for time values. -
To make a project type formattable, specialize
ae::Formatter<YourType>and implementFormat(YourType const&, FormatContext<TStream>&) const. Write output throughctx.out(), or delegate to existing formatters withFormatter<T>{}.Format(value, ctx). For a composed representation, useFormatTo(ctx.out(), FormatScheme{"value={}, count={}"}, value, count):namespace ae { template <> struct Formatter<MyType> { template <typename TStream> void Format(MyType const& value, FormatContext<TStream>& ctx) const { FormatTo(ctx.out(), FormatScheme{"name={}, count={}"}, value.name, value.count); } }; } // namespace ae
- Follow the Google C++ Style Guide.
- Raw pointers are not an anti-pattern in this project. Use them to express a nullable value or a non-owning reference.
- A nullable raw pointer may be checked against
nullptrbefore use. - When a class requires a non-owning reference, accept it as a reference in the constructor and store its address as a raw pointer. This expresses the non-null requirement in the constructor contract; the referenced object must outlive the class that stores the pointer.
- Raw pointers never express ownership. Do not retain them across asynchronous boundaries unless the owning lifetime is explicitly guaranteed.
- Make single-argument constructors
explicitunless implicit conversion is intentional, documented, and accompanied by an explanatoryNOLINT. - Brace
ifandforbodies. Prefer brace initialization; use parentheses for a vector size constructor when that is the intended form. - Prefer
autowhen it preserves the required value, reference, or pointer type. Compare raw pointers withnullptr. - Omit permanently unused parameter names; use
[[maybe_unused]]when usage depends on configuration. - Use
std::invokefor immediately invoked lambdas. - Give assertions explanatory messages, for example
assert(condition && "reason"). - Name internal namespaces
<file_name>_internal. - Follow IWYU. Preserve intentional public umbrella/transitive includes with
an IWYU
keeppragma or exported include block.
- Use Unity and organize tests by subsystem under
tests/. - Put tests in
ae::test_<feature>namespaces, normally matching the test file name. - Name individual tests
test_<PascalCase>. - Define the module suite entry in the global namespace as
int test_<suite>(); group entries dispatch suite entries. - A
using namespacedirective is forbidden except where needed in a suite entry, where it requires an explanatory// NOLINT. - Avoid Unity assertions specialized for
uint64_t/int64_tanddouble; those types or assertion macros are not portable across all targets. Prefer portable values and assertions. - Configure and run the corresponding CTest/Unity tests; a successful CMake configure is not test validation.
Organize examples by feature. Put shared construction and platform helpers under
examples/common. The cloud and A/B message-exchange examples are smoke tests;
benchmarks are not unit tests.
Run smoke tests from the build directory in this order:
- Remove persisted state:
rm -rf ./state. - Run
./ab-message-exchange; require exit code0. - Wait at least six seconds so the server forgets previous connections.
- Run
./ab-message-exchangeagain with the preserved state; require exit code0. - Remove
./stateagain. - Run
./aether-client-cpp-cloud; require exit code0. - Wait at least six seconds.
- Run
./aether-client-cpp-cloudagain with the preserved state; require exit code0.
Do not read or analyze logs until these runs succeed unless log analysis is explicitly requested to prove specific behavior.
Use the regular root CMake project. Keep separate build directories for different compilers, build types, sanitizers, persistence modes, and user configuration headers. A configured build directory retains its CMake options, so inspect or reconfigure it before relying on its settings.
Use a separate build directory such as <build_dir> for each compiler,
platform, build type, sanitizer, persistence mode, or user configuration.
Build and test it with:
cmake --build <build_dir> --parallel
ctest --test-dir <build_dir> --output-on-failureA successful CMake configure is not build or test validation.
aether/config.h provides the built-in configuration defaults. USER_CONFIG
is optional; when defined, aether/config.h includes the selected header before
applying its remaining #ifndef defaults. Therefore a user configuration header
overrides the defaults by defining the relevant AE_* macros.
No user configuration is selected when USER_CONFIG is empty. This is the
project's default behavior and uses the values from aether/config.h.
Select one of the predefined configurations with a path relative to the source tree, for example:
cmake -S . -B build-hydrogen \
-DUSER_CONFIG=config/user_config_hydrogen.h \
-DAE_BUILD_TESTS=ONPredefined configurations are located in config/. Inspect the selected
configuration before changing code that depends on compile-time feature or
cryptography settings.
Custom configuration headers may also be supplied through CMake:
cmake -S . -B build-custom \
-DUSER_CONFIG=/absolute/path/to/my_aether_config.hConfiguration changes require a separate build directory or a CMake reconfigure, and can change available source features and required platform dependencies.
USER_CONFIG is a compile-time configuration header, not persisted state.
FS_INIT optionally supplies generated or static saved-state data:
cmake -S . -B build-with-state \
-DUSER_CONFIG=config/user_config_hydrogen.h \
-DFS_INIT=/absolute/path/to/generated_state.hAE_DISTILLATION and AE_FILTRATION are independent CMake options:
AE_DISTILLATION=ONenables creation of objects from scratch, even when persisted state exists.AE_FILTRATION=ONenables loading existing state and creating missing objects. Inaether/config.h, filtration also definesAE_DISTILLATION=1so code requiring distillation support is compiled.- With both options disabled, production behavior is used: required persistent objects must already exist and be loaded from the domain or copied from prefab objects.
Examples:
# Development: always create state
cmake -S . -B build-distillation -DAE_DISTILLATION=ON -DAE_FILTRATION=OFF
# Hybrid operation: load existing state or create it
cmake -S . -B build-filtration -DAE_DISTILLATION=OFF -DAE_FILTRATION=ON
# Production behavior: neither mode enabled
cmake -S . -B build-production -DAE_DISTILLATION=OFF -DAE_FILTRATION=OFFFS_INIT may provide generated or static persisted-state maps. Persistence is
not implied by mutation; use the established application save path when state
must survive shutdown.
Follow the repository's Google C++ style and warning policy. Keep includes
minimal and preserve intentional public umbrella includes with IWYU annotations.
Use the configured build's compile_commands.json for changed-file clang-tidy
checks. Regenerate the compilation database when compiler, CMake options,
platform, or user configuration changes. Apply formatting consistently with
the repository's existing .clang-format policy before submitting changes.
For ESP-IDF, use the covered project at
projects/xtensa_lx6/vscode/aether-client-cpp. Select the appropriate ESP32
target and select the component through COMPILE_EXAMPLE. Preserve the required
component names cloud, oddity, and send_message_delays; do not rename
them. The Aether component requires the IDF targets idf::esp_wifi,
idf::esp_netif, idf::nvs_flash, idf::spiffs, and
idf::esp_driver_uart.
- Manage dependencies through CPM in the root
CMakeLists.txt. - Do not add Conan, vcpkg, submodules, or vendored dependency copies unless explicitly requested.
- Use
CPM_SOURCE_CACHEfor repeated downloads andCPM_<dependency name>_SOURCEorCPM_USE_LOCAL_PACKAGESfor local development; do not edit or copy dependency sources. - Preserve dependency pins and required patches. Update a patch only when the dependency revision requires it.
- Keep CPM dependencies
EXCLUDE_FROM_ALL FALSEand propagate install options so installation remains complete withAE_INSTALL. - Unity is test-only, c-ares is desktop-only, and ESP-IDF dependencies are supplied by IDF rather than CPM.
- Build AT operations with
at::MakeRequestand provide anat::Waittrigger for every expected response. - Start requests through the normal sender/consumer/waiter path; do not send a command separately.
- Bound every request with
WithTimeout. - Handle modem
ERRORand timeout before issuing dependent commands.