| Platform | Build | Tests |
|---|---|---|
| Linux | ||
| macOS | ||
| Windows | ||
| FreeBSD | ||
| Coverage | |
|---|---|
| Linux (gcc, gcov) | |
| Linux (clang, llvm-cov gcov) | |
Sintra is a C++20 library for type-safe interprocess communication on a single host. It lets independent processes exchange typed messages, broadcast events, and invoke RPC-style calls with a compile-time-checked API, avoiding string-based protocols and external brokers. It also provides coordination primitives such as named barriers, as well as typed publish/subscribe, synchronous and asynchronous RPC, crash detection, and opt-in worker respawning.
Sintra targets low-latency, crash-resilient local IPC where shared-memory transport and coordination need to be integrated rather than assembled from multiple layers. Common alternatives such as ZeroMQ or nanomsg provide local transports, but those are socket-based, cross the kernel boundary, and still copy data. Sintra uses memory-mapped shared rings so data stays in user space and readers access published messages directly, which is suitable for latency-sensitive workloads.
- Key features
- Quick example
- Getting started
- Reference and guide
- Supported platforms and architectures
- Interprocess communication patterns
- Advanced topics
- Tests and continuous integration
- License
- Type-safe APIs across processes - interfaces are expressed as C++ types, so mismatched payloads are detected at compile time instead of surfacing as runtime protocol errors.
- Signal bus and RPC in one package - publish/subscribe dispatch, targeted fire-and-forget messages, and blocking or async remote procedure calls share the same primitives, allowing programs to mix patterns as the architecture requires.
- Header-only distribution - integrate the library by adding the headers to a project; no separate build step or binaries are necessary.
- No RTTI required - type ids are derived from compile-time signatures (or explicit ids when pinned).
- Cross-platform design - shared-memory transport on Linux, macOS, Windows, and FreeBSD.
- Opt-in crash recovery - managed children call
sintra::enable_recovery()to let the coordinator respawn later occurrences of that custody after an unexpected exit. - Lifeline ownership for spawned processes - child processes monitor a lifeline pipe/handle and hard-exit if the owner disappears (timeout and exit code are configurable).
Typical use cases include plugin hosts coordinating work with out-of-process plugins, GUI front-ends that need to communicate with background services, and distributed test harnesses that must keep multiple workers in sync while exchanging strongly typed data.
// Publisher process: announce a shared struct Ping to everyone listening.
sintra::world() << Ping{};
// Receiver process: register a slot so cross-process Pings show up locally.
sintra::activate_slot([](const Ping&) {
sintra::console() << "Received Ping from another process" << '\n';
});- The
include/directory must be on the project's include path. - A C++20 compliant compiler is required (GCC, Clang, or MSVC are supported).
- Start with
example/sintra/for focused samples covering publish/subscribe, ping-pong, RPC, recovery, barriers, and targeted messaging.sintra_example_9_minimal_pubsub.cppis the smallest complete multi-process publish/subscribe program.
Because everything ships as headers, Sintra works well in monorepos or projects that prefer vendoring dependencies as git submodules or fetching them during configuration.
Header-only use remains the default. To compile the non-template message-reader core
once, configure with -DSINTRA_BUILD_COMPILED=ON and link
sintra::sintra_compiled. For builds not using CMake, define
SINTRA_SEPARATE_COMPILATION consistently and compile exactly one source file
containing #include <sintra/src.hpp>.
If Sintra is already available to your build, link the interface target:
target_link_libraries(my_app PRIVATE sintra::sintra)For an in-tree dependency, make the target available first:
add_subdirectory(external/sintra)
target_link_libraries(my_app PRIVATE sintra::sintra)For installed consumers, top-level builds export CMake package metadata. After
cmake --install, use:
find_package(sintra CONFIG REQUIRED)
target_link_libraries(my_app PRIVATE sintra::sintra)For a browser view, build the static reference site and serve it locally:
python scripts/build_reference_site.py
python -m http.server 8000 --directory docs/reference_siteThen open http://localhost:8000/.
The Markdown sources remain available for symbol lookup in docs/reference/index.md, and the narrative guide is docs/guide.md. For reverse lookup from compiler errors, exceptions, or runtime symptoms, use docs/diagnostics.md.
-
Linux, macOS, Windows, FreeBSD - shared-memory transport is supported on all four.
-
CPU architectures - Sintra targets x86/x64 and ARM/AArch64 CPUs. Builds on other architectures still succeed, but they emit a warning and fall back to a simple no-op spin pause for the interprocess primitives, so those primitives may run with a very basic implementation and performance is not guaranteed.
-
macOS requirement - Sintra requires macOS 15.0 or newer with the Command Line Tools for Xcode 15 (or newer) installed (the full Xcode IDE is not required). The build fails if
<os/os_sync_wait_on_address.h>or<os/clock.h>is missing.Older macOS versions are not supported because Sintra's adaptive reader policy relies on
os_sync_wait_on_addressto wait on a specific atomic with timeouts and ordered/unordered wakeup semantics. The pre-15 alternatives all force compromises that hurt the whole library:dispatch_semaphore_tloses fine-grained timeouts and the wakeup-coalescing hooks;pthread_cond_trequires a kernel transition per wake even on the fast path, defeating the spin/precision-sleep/block phases inconfig.h; Machsemaphore_tis per-task rather than per-address and would force rebuilding the wakeup layer on top. After evaluating these options, requiringos_sync_wait_on_addressis the cleanest trade-off.
// Sender process: announce a shared struct Ping to everyone listening.
sintra::world() << Ping{};
// Receiver process: register a slot so cross-process Pings show up locally.
sintra::activate_slot([](const Ping&) {
sintra::console() << "Received Ping from another process" << '\n';
});struct Unicast_receiver : sintra::Derived_transceiver<Unicast_receiver>
{
void handle_unicast(const Ping& msg) {
sintra::console() << "Got targeted ping\n";
}
SINTRA_UNICAST(handle_unicast)
};
// Send to a specific instance id (e.g., exchanged out-of-band or via a broadcast).
Unicast_receiver::rpc_handle_unicast(target_instance_id, Ping{});For full flows, see example/sintra/sintra_example_0_basic_pubsub.cpp,
example/sintra/sintra_example_6_unicast_send_to.cpp, and
example/sintra/sintra_example_2_rpc_append.cpp.
// Wait for a Stop signal (synchronous receive).
sintra::receive<Stop>();
// Wait for a message and capture its payload.
auto msg = sintra::receive<DataMessage>();
sintra::console() << "value=" << msg.value << '\n';Note: call receive<T>() from main/control threads only; do not call it from a message handler.
Debug builds abort if this is violated.
struct Remotely_accessible: sintra::Derived_transceiver<Remotely_accessible>
{
std::string append(const std::string& s, int v) {
return std::to_string(v) + ": " + s;
}
SINTRA_RPC(append); // generates Remotely_accessible::rpc_append(...)
};Usage example:
// Callee process: create and name the instance.
Remotely_accessible ra;
ra.assign_name("instance name");
// Caller process: invoke the RPC.
auto value = Remotely_accessible::rpc_append("instance name", "Hi", 43);
sintra::console() << value << '\n';Async RPC variants (rpc_async_<method>(...)) are also available when a caller
needs to start a request and collect the result later. For the async-handle API
and same-process export details, see the RPC overview comments in
include/sintra/sintra.h and tests/rpc_async_lifecycle_test.cpp.
// Remote exceptions thrown inside append() propagate back across the process boundary.
try {
sintra::console() << Remotely_accessible::rpc_append("instance", "Hi", 43) << '\n';
}
catch (const std::exception& e) {
sintra::console() << "Remote RPC failed in callee: " << e.what() << '\n';
}sintra::set_lifecycle_handler(
[](const sintra::process_lifecycle_event& event) {
if (event.why != sintra::process_lifecycle_event::reason::crash) {
return;
}
sintra::console()
<< "Process "
<< event.process_iid
<< " crashed with status " << event.status << '\n';
});Sintra spawns managed processes with a lifeline pipe/handle. The child watches the read end; if the parent process exits or unpublishes, the pipe breaks and the child shuts down, then hard-exits after a timeout.
You can configure the policy per spawn:
sintra::Spawn_options options;
options.binary_path = binary_path;
options.env_overrides = {"WORKER_MODE=active"};
options.readiness_instance_name = "worker_service";
options.lifetime.hard_exit_code = 99;
options.lifetime.hard_exit_timeout_ms = 100;
auto custody = sintra::spawn_swarm_process(options);
if (!custody) {
throw std::runtime_error("managed-child request rejected");
}
auto launch = custody.wait_for_readiness_until(
std::chrono::steady_clock::now() + std::chrono::seconds(5));
if (launch.readiness_state == sintra::Managed_child_readiness_state::pending) {
// The deadline only bounded this observation. Custody remains open.
auto cleanup = custody.terminate_until(
std::chrono::steady_clock::now() + std::chrono::seconds(5));
}Environment overrides merge with the inherited environment in order; a later
duplicate wins. Variable names are case-insensitive on Windows and
case-sensitive on POSIX, and recovery occurrences reuse the same overrides.
Windows drive current-directory entries such as =C:=C:\work are also
supported. Other entries are not syntax-validated, so provide NAME=VALUE
strings. Custody status also retains occurrence-qualified last_failure
diagnostics. Deadline expiry does not request release or cleanup and does not
itself create a failure.
Use terminate_until() to request cleanup explicitly. The custody handle's
boolean conversion is the validity and acceptance fact; status reports typed
readiness and release states. A
last_failure.kind == Managed_child_failure_kind::none means only that no
failure report has been recorded—not that readiness or release succeeded.
Note: spawned processes require a lifeline by default. To launch a process
manually into an existing swarm, create an external process invitation in the
coordinator and pass External_process_invitation::sintra_args() to that
process. See
docs/reference/external_process_invitation.md
and docs/process_lifecycle_notes.md.
For a Qt widget example that forwards Qt signals through sintra, see
example/qt_basic/README.md.
Sintra uses dedicated reader threads to process incoming messages from shared memory rings. When a message arrives:
- A reader thread pulls the message from the ring buffer.
- The reader thread invokes the matching slot or RPC handler asynchronously.
- Handlers (and their post-handler continuations) execute on the reader thread, not the thread that published the message or called the barrier.
Concurrency reminder: Slot handlers that touch shared state must still synchronize with other threads in the process (via mutexes, atomics, etc.). The barriers described below coordinate when handlers run; they do not eliminate the need for thread-safe data structures.
sintra::barrier() coordinates progress across processes and comes in three flavors that trade off strength for cost. The template defaults to delivery_fence_t, so a plain barrier("name") is already stronger than a bare rendezvous. The lightest-weight barrier whose guarantees match the code's requirements is preferred:
-
Rendezvous barriers (
barrier<sintra::rendezvous_t>(name)) simply ensure that every participant has reached the synchronization point. Messages published before the barrier might still be in flight or waiting to be handled, so this mode is appropriate when only aligned phase progression is needed - for example, coordinating the simultaneous start of a workload whose logic does not depend on the effects of earlier messages.Warning: Two peers can both reach the rendezvous while still missing each other's prior messages (A sends x, B sends y, both call rendezvous, neither is guaranteed to have received the other). Prefer delivery or processing fences when correctness depends on pre-barrier messages being observed.
-
Delivery-fence barriers (
barrier(name)orbarrier<sintra::delivery_fence_t>(name)) guarantee that all pre-barrier messages have been pulled off the shared-memory rings by each process's reader thread and are queued locally for handling, though the handlers may still be running. This is a local guarantee for the caller; it does not add a second rendezvous proving that peers have also drained their readers. The default delivery fence is suitable when the next step requires the complete set of incoming work to be staged locally, such as inspecting an inbox before taking action. -
Processing-fence barriers (
barrier<sintra::processing_fence_t>(name)) wait until every handler (and any continuations) for messages published before the barrier has finished executing. This mode is appropriate when subsequent logic must observe the completed side effects - for instance, reading shared state that earlier handlers updated or applying a configuration change only after all peers processed preparatory updates.
Delivery fences cost the same as rendezvous plus a short wait for readers to catch up. Processing fences add a single control message per process and an extra rendezvous to allow deterministic observation of handler side effects.
Barrier names beginning with _sintra_ are reserved for internal runtime coordination and now fail fast.
// Wait until everyone reaches the same point and any prior messages are queued locally.
sintra::barrier("phase-1"); // delivery fence
// Later: ensure the side effects from earlier messages are visible before reading shared data.
sintra::barrier<sintra::processing_fence_t>("apply-updates");Barrier rounds track processes, not calling threads. For a single
(barrier_name, group_name, BarrierMode) round, each process should have one
in-flight caller; when several threads in the same process must wait for the same
phase, coordinate them with normal threading primitives and have one
representative enter sintra::barrier.
Processing fences are best issued from control threads when the fence must include all pre-barrier handler work. A processing fence called from a request-reader handler or post-handler is reentrancy-aware: it skips the currently executing reader and may run queued post-handlers while waiting, so it does not wait for the current handler/post-handler or for messages queued behind it on that same request-reader stream.
Most multi-process Sintra programs follow this top-level shape:
sintra::init(argc, argv, process_a, process_b, process_c);
// branch-specific work
// ...
sintra::shutdown();shutdown() is the recommended teardown call when every live participant is
expected to finish together. It first performs the library's standard
all-process shutdown handoff, making sure earlier interprocess work has been
fully processed, and then tears down the local runtime.
A false return means that no runtime was active or that the final
managed-child custody join was bounded-incomplete. In the latter case Sintra
retains the runtime and teardown state: settle or terminate the retained
custody, then retry shutdown() sequentially.
The retry skips collective and coordinator-hook phases that already completed.
Concurrent, nested, and reentrant shutdown calls are unsupported. The 250 ms
custody-join bound is not a deadline for the complete shutdown protocol.
If one process is intentionally departing while peers continue running, use
sintra::leave(). If the coordinator must run a bounded final local action before
raw teardown, use sintra::shutdown(options).
In other words:
- Use
shutdown()for the ordinary "all processes are done, now exit cleanly" case. - Use
leave()for intentional unilateral departure.
Low-level lifecycle escape hatches and shutdown internals are documented in
docs/barriers_and_shutdown.md and
docs/process_lifecycle_notes.md.
Most users do not need explicit type ids when every process uses the same compatible build. When ids must remain stable across ABI-compatible build boundaries, they can be pinned explicitly for both transceivers and messages. Explicit ids stabilize identity only. Every joining process must present exactly the coordinator's startup ABI token. That token contains compiler, standard-library, platform, and architecture identities plus the Sintra ring ABI version. A token mismatch, including between MSVC and MinGW builds, is rejected before that joining process opens its request/reply message rings. A matching token is required, but it does not guarantee that raw C++ object representations are interoperable; explicit ids provide no such guarantee either. The ids must remain unique and consistent across every process in the swarm.
struct Explicit_bus : sintra::Derived_transceiver<Explicit_bus>
{
SINTRA_TYPE_ID(0x120)
SINTRA_MESSAGE_EXPLICIT(ping, 0x121, int value)
};
Explicit_bus bus;
sintra::activate_slot([](const Explicit_bus::ping& msg) {
sintra::console() << "ping value=" << msg.value << '\n';
});
bus.emit_global<Explicit_bus::ping>(73);See example/sintra/sintra_example_7_explicit_type_ids.cpp for a full example.
The library includes a comprehensive test suite covering publish/subscribe, RPC,
barriers, and crash recovery. Test runs are selected by tests/active_tests.txt.
cmake -B build -DSINTRA_BUILD_TESTS=ON
cmake --build build
cd tests && python3 run_tests.py --build-dir ../build --config ReleaseSee TESTING.md for detailed documentation.
CI runs on Linux, macOS, Windows, and FreeBSD — all on GitHub Actions (FreeBSD in a QEMU VM via vmactions/freebsd-vm).
The source code is licensed under the Simplified BSD License.