The Story of the X Project
Why This Project Exists
C++11 introduced rvalue references, and with them came move semantics — a mechanism that at first glance seems like a performance optimization (avoid copying large objects) but is actually something far more profound. Before move semantics, C++ had two ways to pass a value: copy it, or pass a pointer. Copying is safe but expensive; pointers are cheap but dangerous — nothing in the type system tells you who owns the pointed-to memory, when it will be freed, or whether it's even still valid. This is why C++ codebases are haunted by "who frees this?" and "is this pointer still alive?" — questions that don't exist in garbage-collected languages, and that C++ developers pay for with valgrind sessions, ASan runs, and late-night debugging.
// Three ways to pass a buffer to another function:
// 1. Copy — safe, but deep-copies gigabytes of video frame data.
void process_copy(std::vector<uint8_t> buf); // caller knows buf is copied
// 2. Raw pointer — cheap, but who owns this? Who frees it?
void process_ptr(uint8_t* data, size_t len); // caller: "is data still valid?"
// 3. Rvalue reference — cheap AND clear. "I'm done with this, it's yours."
void process_move(std::vector<uint8_t>&& buf); // caller: std::move(buf)
// callee: sole owner, RAII cleanup
Traditional C++ leans on copies (too expensive for large objects) or pointers (too ambiguous for ownership). Neither encodes who holds the value into the function signature.
Move semantics bridges this gap. When you std::move a value, you're not copying bits — you're transferring ownership. The source object is left in a valid-but-unspecified state, and the destination assumes full responsibility. Combined with RAII destructors, move semantics lets you express in the type system: "I am the only one who holds this resource, and when I go out of scope, it gets cleaned up." No reference counting, no garbage collector, no manual free.
Rust took this idea and made it the foundation of the language — every value has exactly one owner, the compiler enforces borrowing rules at compile time, and you get memory safety without a runtime. C++ can't match Rust's compiler-level guarantees, but we can get surprisingly close with a library. That's where libxpp comes in: a C++11 wrapper that uses move semantics to implement Own<T> (single-owner heap allocation), Box<T>, Rc<T> / Arc<T> (shared ownership), NonNull<T> (non-null pointer abstraction), and a family of Rust-inspired types like Option<T>, Result<T, E>, and Enum<Ts...>. The goal is to make value semantics, especially move semantics, the default way you write C++ — so your code reads like "I have a value, I move it to you" rather than "here's a pointer, please don't forget to free it."
Beyond Smart Pointers
Solid value types are necessary but not sufficient. A modern language also needs a good story for asynchronous I/O. The C/C++ ecosystem has no shortage of event libraries — libevent, libev, and my personal favorite, libuv — but these are event notification libraries, not async programming frameworks. They tell you that a socket became readable, but they don't give you the experience you get in Go or Rust: writing sequential-looking code that suspends and resumes across I/O boundaries.
To go from "the event loop told me there's data" to "I wrote .await() and it just worked" requires building: a scheduler that can park and resume tasks, a mechanism to yield and be re-polled, a standard API for chaining async operations, combinators for running tasks concurrently or racing them against timeouts, integration with the event loop's I/O multiplexing, and — crucially — a way to propagate errors through the async chain without losing type information. This is the sheer amount of infrastructure that separates a raw event library from an async runtime.
A natural question: why not just use Boost.Asio? Asio is the most mature async library in the C++ ecosystem, but it was designed before C++11 was widespread. It's built on a callback chain and io_service scheduling model where the type system plays a minimal role and error handling is almost entirely error_code. Coroutine support was retrofitted with macros and templates — it wasn't designed in from the start. We wanted an async stack where type safety, move semantics, and multiple await styles are first-class citizens from day one.
The Promise<T> Abstraction
Rust's Future trait is the blueprint: an async operation is a state machine that, when polled, either returns Poll::Ready(value) or Poll::Pending and registers a waker to be called when progress can be made. The executor drives the state machine by calling poll() in a loop until the future completes.
C++ doesn't have this trait as a language feature, but it gives us the tools to build it. Our Promise<T> is a concrete template with the polling interface poll(waker) → Option<T> — it holds a type-erased node (coroutine frame, adapter, or chain), but from the user's perspective, Promise<T> is always fully typed and the compiler checks every call site.
When you call .await() on a Promise, it enters a polling loop: try poll(), and if the value isn't ready, park the current context so the event loop can make progress. Once the Promise resolves — an I/O completes, a timer fires, a channel receives — the waker fires, the polling loop un-parks, and poll() returns the value. This is the same core mechanism that powers tokio, just implemented at the library level rather than in the language runtime. The full design is documented in the Promise chapter.
How to Use It
Because everything converges on poll(), Promise<T> supports three coding styles — each equally valid, each using the same underlying machinery:
1. .await() — any C++11 compiler
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
int result = fetch_value() // Promise<int>
.then([](int x) { return x * 2; })
.await(); // runs event loop until resolved
.await() drives the event loop itself — it calls xEventLoopRun(X_RUN_ONCE) in a loop until the Promise resolves. This is the universal entry point: it works in main(), in tests, anywhere a WaitScope is active.
2. .await() + fiber — non-blocking concurrency
xpp::fiber([]() {
auto a = http_get("/a").await(); // fiber suspends, event loop continues
auto b = http_get("/b").await(); // resumes when a is ready
return a + b;
}).then([](int total) {
printf("total = %d\n", total);
});
Wrap your code in xpp::fiber() and .await() automatically becomes non-blocking. The fiber gets its own 64 KiB mmap'd stack with a guard page. When .await() needs to wait, it calls swapcontext to switch back to the event loop — the fiber freezes in place, and the thread can run other fibers or handle I/O. When the Promise resolves, the waker switches back to the fiber exactly where it left off.
This is the headline feature of libxpp: C++11, no co_await syntax, no colored functions, no compiler support needed. You get the same linear-code experience as Rust's .await or Go's goroutines, on any C++11 toolchain.
3. co_await / co_return — C++20 coroutines
xpp::Promise<int> compute() {
int x = co_await fetch_value();
co_return x * 2;
}
If you have a C++20 compiler, Promise<T> is directly a coroutine return type. co_await compiles into the same poll() / waker mechanism as .then() chains — no separate runtime, no Task<T> wrapper.
All three styles interoperate freely. A Promise<T> returned by .then() can be .await()'d in a fiber, a coroutine can co_await a Promise built from a callback chain, and .then() can append a callback to a Promise returned by a coroutine. The library doesn't care which style you choose — it's the same poll() underneath.
Building the Async Stack
With Promise<T> as our foundation, we can build the same async modules that Rust developers reach for:
- I/O utilities:
BufReader/BufWriterfor efficient buffered I/O,io::copy()andio::read_all()for common patterns,Duplex/Simplexfor in-process communication - Network:
TcpStreamfor async TCP,TcpListenerfor accepting connections,UdpSocket, DNS resolution, TLS with OpenSSL or mbedTLS - FileSystem: async
Filewith cursor tracking,stat, directory operations - Channels:
oneshot(single-value),mpsc(bounded and unbounded),broadcast(multi-consumer with lag detection),watch(version-tracked latest value), plusNotifyfor bare signal coordination
We deliberately align our API with both the STL (naming conventions, iterator patterns) and Tokio (channel semantics, async method signatures, error types). A Rust developer should recognize mpsc::channel::<T>(cap); a C++ developer should find rx.recv() and tx.send(v) familiar. This dual alignment is a design constraint, not an afterthought.
The Foundation: libx
All of this runs on top of libx, a C99 library that provides the event loop, non-blocking I/O, timers, lock-free queue primitives, and (since xbase/fiber.h) cross-platform stackful fibers with xFiberCreate / xFiberSwitch. libx was built with the same philosophy: give C developers an async runtime they can start using immediately — no callback hell, no manual fd management, just xTcpConnect() and a promise-like callback. libxpp is the C++ layer that adds type safety, move semantics, .await() ergonomics, and xpp::fiber() integration on top — the name says it directly: the C core is libx, the C++ binding is libxpp.
Into the Design
If you want to dive into the details behind each piece:
- Type System — how
Own<T>,Box<T>,Rc<T>,Arc<T>, andNonNull<T>implement Rust-style ownership in a library, and where the limits are compared to a compiler-enforced borrow checker. - Promise Model — the poll-and-waker state machine,
.await()semantics (fiber suspend + direct event loop drive), C++20 coroutine frame mapping, and the internals of chaining, cancellation, and error propagation. - Async I/O — the layering from raw
AsyncFdup throughBufReader/BufWriterto type-safeTcpStreamandFile, plus utilities likeio::copyand in-processDuplex/Simplexpipes. - Channels — the full Tokio-aligned suite:
oneshot,mpsc(bounded via lock-free ring buffer, unbounded via lock-free linked list),broadcastwith lag recovery,watchwith version-tracked "seen" semantics, andNotifyas a reusable wake primitive. - Threading Model —
Arc<T>(atomic refcount) for all shared library state — the oldXPP_MTswitch andShared<T>alias were removed,Rc<T>remains for explicit single-threaded use — theloommodule of swappable primitives for future concurrency testing, and RAII close semantics across all channels. - Network — async TCP, UDP, DNS, and TLS, all built on the same
Promise<T>foundation. - Filesystem — async file I/O with cursor tracking,
stat, and directory operations. - Time(TODO) — tokio-style time primitives —
Instant,Duration,sleep,interval,timeout— built onPromise<T>.
The design philosophy throughout is the same: leverage what C++ gives us (move semantics, RAII, coroutine code generation) to build an async experience that feels like Rust with Tokio, still runs on a C foundation, and fits into existing C++ codebases without requiring a language fork or a custom compiler.
The result is a stack where C and C++ each get the async experience they deserve: libx for systems programmers who need raw control with structured concurrency, and libxpp for application developers who can choose between xpp::fiber([]() { auto v = promise.await(); ... }) in C++11, Promise<T>::then([](auto v){...}) for callback chains, or co_await promise for C++20 coroutines — no pointers, no leaky abstractions, no callback pyramids.
X 项目设计哲学
为什么要做这个项目
C++11 引入了右值引用,随之而来的是移动语义——这个机制表面上看是一个性能优化(避免拷贝大对象),但它实际上要深刻得多。在移动语义出现之前,C++ 只有两种传递值的方式:拷贝,或者传指针。拷贝是安全的但昂贵;指针廉价但危险——类型系统里没有任何东西告诉你谁拥有这块内存、它什么时候会被释放、甚至它是否还有效。这就是为什么 C++ 代码库里总是萦绕着"这该谁 free?"和"这指针还活着吗?"之类的问题——这些问题在有 GC 的语言里根本不存在,C++ 开发者只能自己承担,靠 valgrind 跑内存泄漏、靠 ASan 查越界、靠一次次调试定位悬挂指针。
// 三种把 buffer 传给另一个函数的方式:
// 1. 拷贝 — 安全,但会深拷贝上 GB 的视频帧数据。
void process_copy(std::vector<uint8_t> buf); // 调用方知道 buf 被拷贝了
// 2. 裸指针 — 廉价,但这块内存谁持有?谁来释放?
void process_ptr(uint8_t* data, size_t len); // 调用方:"data 还有效吗?"
// 3. 右值引用 — 廉价且语义清晰。"我用完了,归你了。"
void process_move(std::vector<uint8_t>&& buf); // 调用方:std::move(buf)
// 被调方:独享所有权,RAII 自动清理
传统 C++ 要么靠拷贝(大对象太贵),要么靠指针(所有权模糊)。两者都无法 在函数签名里编码"谁持有这个值"。
移动语义填补了这个鸿沟。当你对某个值 std::move 时,你并不是在拷贝字节——你是在转移所有权。源对象被留在一个"有效但未指定"的状态,而目标对象承担全部责任。配合 RAII 析构,移动语义让你可以在类型系统里表达:"我是唯一持有这个资源的人,当我离开作用域时,它就会被清理。"不需要引用计数,不需要垃圾回收,不需要手动 free。
Rust 接过了这个理念并把它做成了语言的基础——每个值有且仅有一个所有者,编译器在编译期强制检查借用规则,你不需要运行时就能获得内存安全。C++ 做不到 Rust 那种编译器级别的保证,但能用库来做相当接近的事情。这就是 libxpp 的由来:一个 C++11 封装库,利用移动语义实现了 Own<T>(单所有者堆分配)、Box<T>、Rc<T> / Arc<T>(共享所有权)、NonNull<T>(非空指针抽象),以及一整套 Rust 风格的类型:Option<T>、Result<T, E>、Enum<Ts...>。目标是让值语义——特别是移动语义——成为写 C++ 的默认方式,这样代码读起来就是"我有一个值,我把它移动给你",而不是"给你指针,别忘了释放"。
智能指针只是开始
扎实的值类型是必要的,但还不够。一门现代语言还需要一个好的异步 I/O 叙事。C/C++ 生态不缺事件库——libevent、libev,以及我个人最爱的 libuv——但这些是事件通知库,不是异步编程框架。它们告诉你某个 socket 可读了,但不给你 Go 或 Rust 那种体验:写一段看起来是顺序执行的代码,在 I/O 边界处自动挂起和恢复。
要从"事件循环告诉我来数据了"走到"我写下 .await() 它就自己跑起来了",需要构建的东西包括:一个能挂起和恢复任务的调度器、一种让出执行权并等待被重新 poll 的机制、一套串联异步操作的标准 API、能并发执行或竞速超时的组合器、与事件循环的 I/O 多路复用的集成,以及——最关键的是——在不丢失类型信息的前提下把错误沿着异步链路传播出去的方式。这些东西,就是区分一个裸事件库和一个异步运行时的"非常多的东西"。
也许有人会问:为什么不直接用 Boost.Asio?Asio 确实是 C++ 生态里最成熟的异步库,但它的问题在于设计时 C++11 还没普及,整个库建立在一个庞大的回调链和 io_service 调度模型上,类型系统参与感很弱,错误处理几乎全靠 error_code。Coroutine 支持是后期用宏和模板"粘"上去的,而非原生设计。我们想要的是一个从头开始就把类型安全、移动语义和多种 await 方式作为一等公民来设计的异步栈。
Promise<T> 抽象
Rust 的 Future trait 是蓝图:一个异步操作就是一个状态机,被 poll 的时候要么返回 Poll::Ready(value),要么返回 Poll::Pending 并注册一个 waker 以便在有进展时被唤醒。执行器通过循环调用 poll() 来驱动状态机,直到 future 完成。
C++ 没有这个 trait 作为语言特性,但它给了我们实现它的工具。我们的 Promise<T> 是一个具体模板,提供 poll(waker) → Option<T> 的 polling 接口——它内部持有一个类型擦除后的节点(协程帧、适配器或链),但从使用者角度看,Promise<T> 始终是完整类型的,编译器在每一个调用点都会检查。
当你对一个 Promise 调用 .await() 时,它进入一个 polling 循环:先尝试 poll(),如果值还没就绪,就 park 当前上下文让事件循环推进进度。一旦 Promise 被 resolve——某个 I/O 完成、定时器触发、channel 收到值——waker 被调用,polling 循环解除 park,poll() 返回值。这就是驱动 tokio 的核心机制,只不过我们是在库层面实现的,而不是在语言运行时里。完整设计见 Promise 章节。
如何使用
因为一切最终都收敛到 poll() 上,Promise<T> 支持三种编码风格——每种同等有效,共用同一套底层机制:
1. .await() —— 任意 C++11 编译器
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
int result = fetch_value() // Promise<int>
.then([](int x) { return x * 2; })
.await(); // 驱动事件循环直到 resolved
.await() 自己驱动事件循环——它在一个循环里调用 xEventLoopRun(X_RUN_ONCE),直到 Promise resolve。这是最通用的入口:main() 里能用,测试里能用,任何有 WaitScope 的地方都能用。
2. .await() + fiber —— 非阻塞并发
xpp::fiber([]() {
auto a = http_get("/a").await(); // fiber 挂起,事件循环继续
auto b = http_get("/b").await(); // a 就绪后恢复
return a + b;
}).then([](int total) {
printf("total = %d\n", total);
});
把代码包进 xpp::fiber(),.await() 就自动变成非阻塞的。fiber 有自己的 64 KiB mmap 栈(带 guard page)。当 .await() 需要等待时,它调用 swapcontext 切回事件循环——fiber 原地冻结,线程可以跑其他 fiber 或处理 I/O。Promise resolve 时,waker 切回 fiber,精确地从断点继续执行。
这是 libxpp 的主打特性:C++11,不需要 co_await 语法,不需要给函数染色,不需要编译器支持。 你得到的是和 Rust .await、Go goroutine 一样的线性代码体验,用在任何 C++11 工具链上。
3. co_await / co_return —— C++20 协程
xpp::Promise<int> compute() {
int x = co_await fetch_value();
co_return x * 2;
}
如果你有 C++20 编译器,Promise<T> 直接就是协程返回类型。co_await 编译到同样的 poll() / waker 机制上——不需要独立的运行时,不需要 Task<T> 包装。
三种风格可以自由混用。.then() 链返回的 Promise<T> 可以在 fiber 里 .await(),协程可以 co_await 一个由回调链构建的 Promise,.then() 也可以接在协程返回的 Promise 后面。库不关心你选哪种风格——底层都是同样的 poll()。
构建异步技术栈
以 Promise<T> 为基础,我们可以构建 Rust 开发者熟悉的那套异步模块:
- I/O 工具:
BufReader/BufWriter做高效缓冲读写,io::copy()和io::read_all()处理常见模式,Duplex/Simplex做进程内通信 - 网络:
TcpStream做异步 TCP,TcpListener接受连接,UdpSocket、DNS 解析、基于 OpenSSL 或 mbedTLS 的 TLS - 文件系统:带游标追踪的异步
File、stat、目录操作 - Channel:
oneshot(单值)、mpsc(有界和无界)、broadcast(多消费者 + 滞后检测)、watch(版本追踪的最新值),外加Notify做纯粹的信号通知
我们在 API 设计上刻意同时对齐 STL(命名惯例、迭代器模式)和 Tokio(channel 语义、异步方法签名、错误类型)。Rust 开发者看到 mpsc::channel<int>(cap) 应该会感到熟悉,C++ 开发者看到 rx.recv() 和 tx.send(v) 也不会有距离感。这不是一个附加的偏好,而是一个设计约束。
底层基础:libx
所有这一切都运行在 libx 之上,一个 C99 库,提供了事件循环、非阻塞 I/O、定时器、无锁 MPSC 队列,以及(自 xbase/fiber.h 起)跨平台有栈 fiber(xFiberCreate / xFiberSwitch)。libx 建基于同样的理念:给 C 开发者一个开箱即用的异步运行时——没有回调地狱,不需要手动管理 fd,只需要 xTcpConnect() 和一个 promise 式的回调。libxpp 是在此之上的 C++ 层,增加了类型安全、移动语义、.await() 易用性,以及 xpp::fiber() 集成——名字本身就是这个关系:底层 C 库叫 libx,上层 C++ 库自然就叫 libxpp。
走进设计
如果上面的内容让你想进一步了解每个模块的细节,以下是各部分的入口:
- 类型系统 —
Own<T>、Box<T>、Rc<T>、Arc<T>、NonNull<T>如何在库层面实现 Rust 风格的所有权,以及和编译器强制 borrow checker 相比的边界在哪 - Promise 模型 — poll-waker 状态机,
.await()语义(fiber 挂起 + 直接驱动事件循环),C++20 协程帧如何映射到Promise<T>,串联、取消和错误传播的内部机制 - 异步 I/O — 从原始
AsyncFd往上经过BufReader/BufWriter到类型安全的TcpStream和File的分层架构,以及io::copy、Duplex/Simplex等工具 - Channel — 完整的 Tokio 对齐套件:
oneshot、mpsc(有界用无锁环形缓冲区,无界用无锁链表)、带滞后恢复的broadcast、版本追踪"已读"语义的watch,以及可复用的唤醒原语Notify - 线程模型 — 所有共享库状态统一使用
Arc<T>(原子引用计数)——旧的XPP_MT开关和Shared<T>别名已移除,Rc<T>保留给显式单线程使用;loom模块提供可替换的并发原语用于未来的并发测试,以及所有 channel 的 RAII close 语义 - Network — 异步 TCP、UDP、DNS、TLS,全部建立在同一个
Promise<T>基础上 - Filesystem — 异步文件 I/O,带游标追踪,支持 stat、目录操作等
- Time(TODO) — Tokio 风格的时间原语 —
Instant、Duration、sleep、interval、timeout— 全部基于Promise<T>
贯穿始终的设计哲学是同一个:善用 C++ 已经给我们的东西(移动语义、RAII、协程代码生成),构建一个用起来像 Rust + Tokio 的异步体验,底层跑在 C 的基础上,能融入现有 C++ 项目而无需语言分支或自定义编译器。
最终形成的技术栈让 C 和 C++ 各自获得它们应得的异步体验:libx 给需要结构化并发的系统程序员,libxpp 给可以选用 xpp::fiber([]() { auto v = promise.await(); ... })(C++11)、Promise<T>::then([](auto v){...})(回调链)或 co_await promise(C++20 协程)的应用开发者——没有裸指针,没有泄漏的抽象,没有回调金字塔。
libxpp
C++11 bindings for libx — stackful fibers, smart pointers, async primitives, and type utilities. Header-only.
At a Glance
#include <xpp/arc.h>
#include <xpp/box.h>
#include <xpp/option.h>
#include <xpp/result.h>
#include <xpp/promise.h>
#include <xpp/fiber.h>
// .await() — THE way to wait for a Promise. Works everywhere:
// outside a fiber: drives xEventLoopRun directly (blocking)
// inside a fiber: suspends via xFiberYield (non-blocking)
int result = xpp::resolve(42)
.then([](int x) { return x * 2; })
.await();
// result == 84
// Coalesce many concurrent I/O calls with xpp::fiber():
xpp::fiber([]() {
auto a = http_get("/a").await(); // fiber suspends, event loop keeps running
auto b = http_get("/b").await(); // resumes when a is ready
return a + b;
}).then([](int total) {
printf("total = %d\n", total);
});
// Also supports C++20 coroutines (co_await / co_return):
#if XPP_HAS_COROUTINES
xpp::Promise<Stats> fetch() {
auto raw = co_await http_get("/api/stats");
co_return parse_stats(raw);
}
#endif
// Result<T, E> — explicit error handling, no exceptions
xpp::Result<int, std::string> parse(std::string_view s) {
if (s.empty()) return xpp::err("empty input");
return xpp::ok(std::stoi(std::string(s)));
}
// Option<T> — nullptr == None, sizeof == sizeof(T*)
xpp::Option<xpp::Arc<Config>> cached = lookup(key);
if (cached) use(**cached);
Design philosophy:
.await()first — fiber + event loop —.await()is the canonical way to wait. Outside a fiber it drivesxEventLoopRundirectly. Inside a fiber (viaxpp::fiber()) it suspends viaxFiberYield— non-blocking, stackful, M:N concurrency withoutco_awaitsyntax. C++20 coroutines are a first-class option too.- Rust-inspired, C++11-compatible —
Result/Option/Arc/Boxwith the same semantics as their Rust counterparts, but portable to any C++11 toolchain. - Zero overhead — every smart pointer is
sizeof(T*).Option<Arc<T>>is alsosizeof(T*)via niche optimization (nullptr = None). Empty allocators vanish via EBO. - Single allocation —
Arc::make()allocates the control block and value together in one heap block, matching Rust'sArc::new.
Modules
- EventLoop & WaitScope — RAII wrappers for the libx event loop
- Fiber — Stackful coroutines via
xpp::fiber()+.await() - Promise — Composable deferred values
- .await() & Waiting —
.await()semantics (fiber + blocking), event loop integration - Deferred Resolution —
async(),PromiseResolver, cross-thread - Timers & Timeouts —
after(), timeout pattern - Combinators (all/race) —
all(),race(), waker sharing - Utilities (try_next) —
try_next(), sequential fall-through - Custom Adapters —
adapt(),work(), Adapter contract - C++20 Coroutines —
co_await/co_return(optional) - Internals — PromiseNode hierarchy, poll-based model
- .await() & Waiting —
- Allocator — Allocator protocol, GlobalAllocator, custom allocators
- Arena — Bump allocator for short-lived objects (
Arena<N>) - Smart Pointers —
Own,Box,Rc/Weak,Arc/ArcWeak,NonNull- Own — Nullable unique ownership
- Box — Non-null unique ownership
- Rc & Weak — Single-thread shared ownership
- Arc & ArcWeak — Thread-safe shared ownership
- NonNull — Non-owning, non-null reference
- Result — Success or error (Rust Result)
- Option — A value or nothing (Rust Option)
- String — UTF-8 string (Rust String)
- Vec — Contiguous growable array (Rust Vec)
- Enum — Type-safe tagged union
- Timer — Callback-based timer with pause/resume
- Filesystem — Async file I/O (
File,stat,exists,create_dir,rename) - I/O — Reactive async I/O for non-blocking fds (
AsyncFd,read,write,Error) - Net — Async TCP/UDP/DNS/URL/TLS (
TcpStream,TcpListener,UdpSocket,lookup_host,Url,TlsContext) - Panic — Assert macros
- Compiler Macros — Attribute/deprecation helpers
- Opaque Handle Wrapper — RAII for
XDEF_HANDLEtypedefs
Allocator
Introduction
libx++ smart pointers (Arc, Rc, Own, Box) accept an optional Allocator template parameter that controls how the control block (or the pointed-to object) is allocated and deallocated. The default GlobalAllocator uses ::operator new / ::operator delete and is empty (zero overhead via EBO).
The protocol is modeled after Rust's std::alloc::Allocator trait: allocate returns a fat-pointer Span<uint8_t> (pointer + actual size), deallocate takes a Layout (size + align), and grow/shrink are optional with default implementations provided.
Design Philosophy
-
Rust-style
&self.allocateanddeallocateareconst-qualified. Stateful allocators track state viamutablemembers or atomic pointers —std::atomic<T>::fetch_addis itselfconst, so counters held by pointer work without anymutabledance. This lets callers passconst Allocator&if they only have a const reference. -
Layout bundles size + align. Passing them as a pair avoids bugs from mismatched
deallocate(ptr, size)calls where the size doesn't match the original allocation's alignment.Layout::of<T>()derives both from the type, so callers rarely construct one by hand. -
Fat-pointer return.
allocatereturnsResult<Span<uint8_t>, AllocError>rather thanResult<void*, AllocError>. TheSpancarries the actual allocated size (which may be larger than requested — allocate-at-least semantics). Smart pointers ignore the slack; callers that want it (e.g. for a growable buffer) can use it. -
Empty allocator → zero overhead.
GlobalAllocatoris an empty class. EBO (via inheritance forArcInner/RcInner, viaCompressedPairforOwn/Box) collapses it to zero bytes, sosizeof(Arc<T>) == sizeof(T*)with the default allocator. -
Allocator lives in the control block, not the handle. For
Arc/Rc, theAllocatorinstance is stored insideArcInner/RcInner— not inside theArc/Rchandle. This keepssizeof(Arc<T, Allocator>) == sizeof(T*)for anyA, stateful or not. ForOwn/Box, theAllocatoris stored viaCompressedPair<T*, Allocator>and grows the handle when stateful (no separate control block to hide it in).
Architecture
graph TD
subgraph "Allocator protocol"
L["Layout { size, align }"]
S["Span<uint8_t> { data, size }"]
E["AllocError (empty)"]
A["Allocator::allocate(Layout) const → Result<Span, AllocError>"]
D["Allocator::deallocate(void*, Layout) const noexcept"]
G["Allocator::grow / shrink (optional)"]
A --> L
A --> S
A --> E
D --> L
G --> L
end
subgraph "GlobalAllocator (default)"
GA["allocate: ::operator new(size, align)"]
GD["deallocate: ::operator delete(ptr, size, align)"]
end
subgraph "Smart pointer storage"
ARC["ArcInner<T, A> { strong, weak, value, alloc }"]
OWN["CompressedPair<T*, A> { ptr, alloc }"]
end
A --> GA
D --> GD
A --> ARC
A --> OWN
Layout
struct Layout {
size_t size;
size_t align;
template <class T> static Layout of(); // sizeof(T), alignof(T)
static Layout array(size_t n, size_t a); // n bytes, a alignment
};
AllocError
AllocError is an empty struct. allocate returns Result<Span<uint8_t>, AllocError> — callers must handle the error path. (Smart pointers panic on allocation failure, matching the existing ::operator new behavior that throws std::bad_alloc.)
Span<uint8_t>
Span<uint8_t> is a non-owning fat pointer (pointer + length). The length is the actual allocated size, which may be larger than the requested layout.size. Smart pointers ignore the length; it's there for callers that want to use the slack.
GlobalAllocator
The default. Empty class → EBO-eligible → zero storage overhead in ArcInner / CompressedPair.
struct GlobalAllocator {
Result<Span<uint8_t>, AllocError> allocate(Layout layout) const;
void deallocate(void *ptr, Layout layout) const;
};
Uses C++17 aligned ::operator new / ::operator delete when available; falls back to non-aligned on C++11.
API Reference
Required methods
| Signature | Description |
|---|---|
Result<Span<uint8_t>, AllocError> allocate(Layout layout) const | Allocate at least layout.size bytes with layout.align alignment. Returns the allocated span (pointer + actual size, >= layout.size) or an AllocError. |
void deallocate(void *ptr, Layout layout) const noexcept | Free memory previously returned by allocate. layout must match the Layout passed to allocate. |
Optional methods
| Signature | Description |
|---|---|
Result<Span<uint8_t>, AllocError> grow(void *ptr, Layout old_l, Layout new_l) const | Grow an existing allocation. If not provided, default_grow (allocate + memcpy + deallocate) is used. |
Result<Span<uint8_t>, AllocError> shrink(void *ptr, Layout old_l, Layout new_l) const | Shrink an existing allocation. If not provided, default_shrink is used. |
Default grow / shrink
Free functions that implement grow/shrink as allocate + memcpy + deallocate. Used when the allocator doesn't provide its own.
template <class A>
Result<Span<uint8_t>, AllocError> default_grow(const A &alloc, void *ptr,
Layout old_l, Layout new_l);
template <class A>
Result<Span<uint8_t>, AllocError> default_shrink(const A &alloc, void *ptr,
Layout old_l, Layout new_l);
Smart pointer factory methods
| Type | Default Allocator | Stateless custom | Stateful custom |
|---|---|---|---|
Arc<T, Allocator>::make(args...) | GlobalAllocator{} | Arc<T, MyAlloc>::make(args...) | — |
Arc<T, Allocator>::make(alloc, args...) | — | — | SFINAE: first arg convertible to A |
Arc<T, Allocator>::make_in(alloc, args...) | Explicit, no SFINAE | Explicit | Explicit |
Own<T>(p), Own<T>(p, alloc) | GlobalAllocator{} | — | Pass alloc instance |
Box<T, Allocator>::from_raw(p, alloc) | GlobalAllocator{} | — | Pass alloc instance |
make() uses SFINAE to detect whether the first argument is an Allocator instance (for stateful allocators) or a constructor argument for T. make_in() is the explicit form that always treats the first argument as the allocator — use it when SFINAE detection is ambiguous (T's first ctor arg is convertible to Allocator).
Usage Examples
Arc with default allocator
auto a = Arc<std::string>::make("hello");
// sizeof(a) == sizeof(std::string*)
Arc with stateful allocator
struct CountingAllocator {
std::atomic<int> *allocs;
std::atomic<int> *deallocs;
CountingAllocator(std::atomic<int> *a, std::atomic<int> *d) : allocs(a), deallocs(d) {}
xpp::Result<xpp::Span<uint8_t>, xpp::AllocError> allocate(xpp::Layout layout) const {
void *p = ::operator new(layout.size);
if (!p) return xpp::Result<xpp::Span<uint8_t>, xpp::AllocError>(xpp::err, xpp::AllocError{});
allocs->fetch_add(1, std::memory_order_relaxed);
return xpp::Result<xpp::Span<uint8_t>, xpp::AllocError>(
xpp::ok, xpp::Span<uint8_t>(static_cast<uint8_t *>(p), layout.size));
}
void deallocate(void *ptr, xpp::Layout) const {
deallocs->fetch_add(1, std::memory_order_relaxed);
::operator delete(ptr);
}
};
std::atomic<int> allocs{0}, deallocs{0};
CountingAllocator alloc(&allocs, &deallocs);
{
auto a = Arc<int, CountingAllocator>::make(alloc, 42); // SFINAE: alloc detected
EXPECT_EQ(allocs.load(), 1);
auto b = a.clone(); // no new alloc — shares inner
EXPECT_EQ(allocs.load(), 1);
}
EXPECT_EQ(deallocs.load(), 1);
Own / Box with custom allocator
struct FileAlloc {
void deallocate(void *p, xpp::Layout) const noexcept {
if (p) fclose(static_cast<FILE *>(p));
}
};
xpp::Own<FILE, FileAlloc> file(fopen("data.txt", "r"), FileAlloc{});
// sizeof(file) == sizeof(FILE*) (FileAlloc is empty → EBO)
make_in for the ambiguous case
struct Logger {
explicit Logger(GlobalAllocator) {} // first ctor arg convertible to Allocator
};
// make(GlobalAllocator{}) would be ambiguous — SFINAE treats it as the alloc.
// Use make_in to force the alloc interpretation:
auto a = Arc<Logger>::make_in(GlobalAllocator{}, GlobalAllocator{});
// ^ alloc ^ Logger ctor arg
grow / shrink
// Default implementation (allocate + memcpy + deallocate):
auto r = xpp::default_grow(alloc, old_ptr, old_layout, new_layout);
// Custom implementation (e.g. in-place realloc):
struct ReallocAllocator {
// ... allocate / deallocate ...
xpp::Result<xpp::Span<uint8_t>, xpp::AllocError> grow(void *ptr, xpp::Layout old_l,
xpp::Layout new_l) const {
void *p = ::realloc(ptr, new_l.size);
if (!p) return xpp::default_grow(*this, ptr, old_l, new_l);
return xpp::Result<xpp::Span<uint8_t>, xpp::AllocError>(
xpp::ok, xpp::Span<uint8_t>(static_cast<uint8_t *>(p), new_l.size));
}
};
Comparison
| Feature | xpp Allocator | std::pmr::memory_resource | Rust Allocator trait |
|---|---|---|---|
| Protocol | allocate/deallocate methods | do_allocate/do_deallocate virtuals | allocate/deallocate methods |
| Return type | Result<Span<uint8_t>, AllocError> | void* (throws on failure) | Result<NonNull<[u8]>, AllocError> |
| Layout | Layout { size, align } | size_t + size_t args | Layout struct |
| grow / shrink | Optional, default impl provided | Not in API | Optional, default impl provided |
| Empty alloc EBO | Yes (inheritance / CompressedPair) | N/A (type-erased) | Yes (zero-sized type) |
| Storage in smart pointer | Control block (Arc/Rc) or CompressedPair (Own/Box) | N/A | Control block (Arc/Rc) |
| Const-qualified | Yes (allocate/deallocate are const) | No (virtual, mutates vtable state) | Yes (&self) |
| Type-erased | No (template param) | Yes (dyn dispatch) | No (generic) |
Implementation Notes
EBO (Empty Base Optimization)
When Allocator is an empty class (like GlobalAllocator), it is stored as a base class of the control block (ArcInner / RcInner) or via CompressedPair (Own / Box), not as a member. This gives zero storage overhead:
sizeof(Arc<T, GlobalAllocator>) == sizeof(T*)sizeof(Own<T, GlobalAllocator>) == sizeof(T*)sizeof(ArcInner<T, GlobalAllocator>) == sizeof(strong) + sizeof(weak) + sizeof(T)(no Allocator byte)
When Allocator is stateful, it is stored as a member and sizeof grows by sizeof(Allocator) (rounded for alignment). The Arc/Rc/ArcWeak/Weak handles themselves stay at sizeof(T*) because the Allocator lives in the control block, not in the handle — only Own/Box grow because they have no separate control block.
EBO is gated on is_empty<A> && !is_final<A> — final classes can't be inherited from, so they fall back to member storage even when empty.
Deallocation lifecycle (Arc/Rc)
The trickiest part of the stateful-allocator path: the Allocator lives inside the control block that it's about to free. The deallocator moves the Allocator out before freeing:
// In arc_dec_weak_and_maybe_dealloc:
if (inner->weak.fetch_sub(1, std::memory_order_release) == 1) {
std::atomic_thread_fence(std::memory_order_acquire);
Allocator a = std::move(inner->alloc_ref()); // move out
Layout layout = Layout::of<ArcInner<T, Allocator>>();
a.deallocate(inner, layout); // free the memory that contained `a`
}
Allocator must be move-constructible (enforced by static_assert in Arc/Rc). For empty allocators the move is trivial; for stateful allocators it should be noexcept (move the resource handle, not copy it). The moved-from Allocator inside inner is never accessed again — its resources are now owned by the local a, whose destructor runs after deallocate returns.
Destruction order (Own/Box)
The destructor calls ~T() explicitly, then alloc.deallocate(ptr, Layout::of<T>()) — separating object destruction from memory deallocation, matching Rust's Allocator trait. This is necessary because deallocate only frees memory; it does not call destructors.
For T = void, ~T() is skipped via tag dispatch (_::destroy_and_dealloc checks std::is_void<T>), and Layout{0, 1} is used as a sentinel — GlobalAllocator::deallocate calls ::operator delete(ptr, 0, align_val_t(1)), which is valid (size 0 is a no-op hint, the actual free still happens).
Shared helpers in allocator.h
allocator.h defines three helpers in xpp::_ shared by all smart pointers:
IsFinal<D>— portableis_final(C++14 /__is_finalintrinsic / fallback tofalse)FirstIsAlloc<Allocator, Args...>— SFINAE: true iff first arg inArgs...is convertible toAllocatordestroy_and_dealloc<T, Allocator>(ptr, alloc)— calls~T()(if not void) thenalloc.deallocate(ptr, Layout::of<T>())
Extracting these to a shared header prevents redefinition when arc.h + rc.h + box.h are included together.
Arena
Introduction
Arena<N> is a fixed-size bump allocator for short-lived objects. Memory is allocated by bumping a pointer forward; individual allocations are never freed — only bulk-freed via arena destruction or reset(). This eliminates per-object malloc/free overhead for groups of objects with the same lifetime.
The size N is a compile-time template parameter. Small arenas (N ≤ 256) store the buffer inline (zero heap allocation); large arenas heap-allocate the buffer at construction (one allocation). allocate() returns nullptr when full — the caller checks and falls back to ::operator new if needed.
Design Philosophy
-
Bump, never free individually. A bump pointer moves forward on each
allocate(). There is no per-object free-list, no fragmentation, no bookkeeping. The entire arena is freed in one shot when destroyed orreset(). -
Compile-time size, automatic storage. The template parameter
Ndetermines the buffer size.ArenaStorage<N>specializes at compile time: N ≤ 256 → inline (the buffer is a member of theArenaobject), N > 256 → heap (the buffer is::operator new'd at construction). The user writesArena<128>orArena<4096>and the storage strategy is automatic. -
nullptron overflow, not auto-grow. When the arena is full,allocate()returnsnullptr. The caller checks and falls back to heap. This keeps the arena simple (no chunk list, no growth logic) and makesowns()a trivial O(1) pointer range check. -
No destructor tracking. The arena does not call
~T()— the caller is responsible for destructing objects beforereset()or arena destruction. This keeps the arena zero-overhead.make<T>()constructs in-place, but the caller must call~T()manually. -
C++11, header-only, no dependencies.
arena.hincludes only<cstddef>,<cstdint>,<new>,<type_traits>,<utility>. No libx dependency.
Architecture
graph TD
subgraph "Arena<N> (N ≤ 256, inline)"
IA["ArenaStorage<N, true>"]
IB["alignas(max_align_t) char buf[N]"]
IP["m_pos → bump pointer"]
IE["m_end → buf + N"]
IA --> IB
IA --> IP
IA --> IE
end
subgraph "Arena<N> (N > 256, heap)"
HA["ArenaStorage<N, false>"]
HB["char* ptr → ::operator new(N)"]
HP["m_pos → bump pointer"]
HE["m_end → ptr + N"]
HA --> HB
HA --> HP
HA --> HE
end
subgraph "allocate(size, align)"
AL["align_up(m_pos, align)"]
AC{"fits?"}
AF["return p, m_pos += size"]
AN["return nullptr"]
AL --> AC
AC -->|yes| AF
AC -->|no| AN
end
ArenaStorage specialization
Inline (N ≤ 256): Heap (N > 256):
┌─────────────────────┐ ┌─────────────────────┐
│ ArenaStorage │ │ ArenaStorage │
│ ┌─────────────────┐ │ │ ┌─────────────────┐ │
│ │ buf[N] │ │ │ │ ptr ────────────┼─┼──→ heap buffer
│ │ ████░░░░░░░░ │ │ │ └─────────────────┘ │
│ └─────────────────┘ │ │ │
├─────────────────────┤ ├─────────────────────┤
│ m_pos, m_end │ │ m_pos, m_end │
└─────────────────────┘ └─────────────────────┘
sizeof = N + ~16 sizeof = ~24
0 heap allocations 1 heap allocation
Memory layout
Arena<256> on the stack:
[buf[0] ... buf[255]] [m_pos] [m_end]
↑ ↑
begin current bump position
└─── capacity = 256 ─┘
allocate(32):
p = align_up(m_pos, align)
m_pos = p + 32
return p
┌──allocated──┬─── remaining ───┐
│ ████████ │ ░░░░░░░░░░░░ │
└─────────────┴─────────────────┘
↑ ↑ ↑
begin m_pos m_end
API Reference
Arena<N>
| Method | Returns | Description |
|---|---|---|
Arena() | Construct. Inline: zero alloc. Heap: one ::operator new(N). | |
allocate(size_t size, size_t align = alignof(max_align_t)) | void* | Bump-allocate. nullptr if overflow. |
make<T>(args...) | T* | Allocate + placement-new. nullptr if overflow. Caller must ~T(). |
owns(const void* p) | bool | True if p is within this arena's buffer. O(1). |
reset() | m_pos = begin. Buffer stays allocated for reuse. | |
total_capacity() | size_t | Buffer size N (constexpr). |
remaining() | size_t | Bytes left before overflow. |
used() | size_t | Bytes handed out so far. |
Move semantics
- Inline arenas: move copies the buffer and adjusts pointers. The moved-from arena is invalidated.
- Heap arenas: move steals the heap pointer. The moved-from arena is invalidated.
- Copy is deleted (bump allocators are not copyable).
owns() — O(1) ownership check
bool owns(const void* p) const {
const char* cp = static_cast<const char*>(p);
return cp >= m_storage.begin() && cp < m_end;
}
Used by dealloc paths to decide: arena-allocated → skip ::operator delete; heap-allocated → call ::operator delete.
Usage Examples
Basic bump allocation
xpp::Arena<256> arena;
void* a = arena.allocate(64); // bump, 0 malloc
void* b = arena.allocate(32); // bump, 0 malloc
void* c = arena.allocate(128); // bump, 0 malloc
// remaining = 256 - 64 - 32 - 128 = 32
void* d = arena.allocate(64); // nullptr — overflow
// Caller checks and falls back to heap:
if (!d) d = ::operator new(64);
Typed allocation with make<T>
xpp::Arena<256> arena;
auto* str = arena.make<std::string>("hello");
auto* num = arena.make<int>(42);
EXPECT_EQ(*str, "hello");
EXPECT_EQ(*num, 42);
EXPECT_TRUE(arena.owns(str));
// Caller must destruct manually (arena doesn't track):
str->~basic_string();
// num is trivially destructible, no need.
Reset for reuse
xpp::Arena<256> arena;
// Phase 1: allocate some objects
arena.make<int>(1);
arena.make<int>(2);
// used = 8 (2 × sizeof(int), assuming alignment)
// Reset — buffer stays, bump pointer goes back to start
arena.reset();
EXPECT_EQ(arena.used(), 0u);
// Phase 2: reuse the same buffer
arena.make<int>(3); // overwrites the old memory
Overflow fallback pattern
xpp::Arena<256> arena;
template <class T, class... Args>
T* alloc_or_heap(Args&&... args) {
T* p = arena.make<T>(std::forward<Args>(args)...);
if (!p) {
// Arena full — fall back to heap
p = new T(std::forward<Args>(args)...);
}
return p;
}
// Deallocation: check owns() to route correctly
template <class T>
void dealloc(T* p) {
if (arena.owns(p)) {
p->~T(); // arena-allocated: just destruct
// memory freed when arena is destroyed/reset
} else {
delete p; // heap-allocated: destruct + free
}
}
Large arena (heap storage)
xpp::Arena<4096> arena; // sizeof(arena) ≈ 24 bytes
// 1 heap allocation for 4KB buffer
void* p = arena.allocate(2048);
EXPECT_TRUE(arena.owns(p));
EXPECT_EQ(arena.total_capacity(), 4096u);
Stack allocation (zero malloc)
void process() {
xpp::Arena<128> arena; // on the stack, 0 malloc
auto* tmp = arena.make<int>(42);
// ...
// arena destructed on scope exit — buffer is stack memory, no free needed
}
Comparison
| Feature | xpp::Arena<N> | kj::Arena | xSlab |
|---|---|---|---|
| Allocation | Bump (forward) | Bump (forward) | Free-list (fixed size) |
| Size | Compile-time (template param) | Runtime (constructor param) | Runtime (constructor param) |
| Growth | Fixed — nullptr on overflow | Chunk list (auto-grow) | Fixed — nullptr on overflow |
| Individual free | No | No | Yes (free-list) |
| Reset/reuse | Yes (reset()) | No | Yes (xSlabReset) |
owns() | O(1) pointer range check | O(1) | O(1) |
| Inline storage | Yes (N ≤ 256, zero malloc) | No (always heap) | No (always heap) |
| Destructors | No tracking | Tracked (reverse order) | No tracking |
| Language | C++11 | C++14 | C99 |
| Thread safety | Single-thread | Single-thread | Single-thread (xSlabMt for multi) |
Implementation Notes
Inline vs heap threshold
The threshold is k_arena_inline_threshold = 256. This is chosen because:
- PromiseNode typical size: 24–48B. 256B fits 5–10 nodes — enough for a
.then()chain. - 256B inline is acceptable for stack allocation (default stack is 1–8MB).
- Larger arenas (4KB+) would waste stack space if inlined.
ArenaStorage specialization
The storage is handled by ArenaStorage<N, bool Inline>:
ArenaStorage<N, true>(N ≤ 256):alignas(max_align_t) char buf[N]— buffer is a member.ArenaStorage<N, false>(N > 256):char* ptr— buffer is heap-allocated at construction, freed at destruction.
Arena<N> has identical code for both cases — it calls m_storage.begin() to get the buffer start. The storage specialization is transparent to the arena logic.
Alignment
The inline buffer is declared alignas(std::max_align_t), ensuring it can hold any type without alignment issues. allocate() calls align_up() to round the bump pointer up to the requested alignment. The alignment padding is counted in used() but not in any allocation that the caller sees.
No destructor tracking
Unlike kj::Arena, xpp::Arena does not maintain a destructor list. Rationale:
- PromiseNode has its own
destroy()virtual method for arena-aware destruction. - Adding a destructor list would add per-allocation overhead (function pointer + linked list node), defeating the purpose of a zero-overhead bump allocator.
- Callers who need destructor tracking can layer it on top (maintain their own list of objects to destruct).
Move semantics for inline arenas
Moving an inline arena is tricky: m_pos and m_end point into the old object's buf[N] member. After move, the buffer contents are copied (memcpy), and the pointers are recomputed relative to the new object's buffer:
Arena(Arena&& o) : m_storage(std::move(o.m_storage)) {
if (N <= k_arena_inline_threshold) {
size_t used = static_cast<size_t>(o.m_pos - (o.m_end - N));
m_pos = m_storage.begin() + used;
m_end = m_storage.begin() + N;
} else {
m_pos = o.m_pos; // heap: pointers are absolute
m_end = o.m_end;
}
}
For heap arenas, the pointer is stolen (no copy needed) and the old arena is invalidated.
Smart Pointers
Rust-inspired smart pointers with sizeof == sizeof(T*) guarantees. All are header-only, C++11-compatible.
Overview
| Type | Ownership | Thread-safe | Header |
|---|---|---|---|
Own<T, Allocator> | Unique, nullable | No | own.h |
Box<T, Allocator> | Unique, non-null | No | box.h |
Rc<T, Allocator> | Shared | No | rc.h |
Weak<T, Allocator> | Weak observer for Rc | No | weak.h |
Arc<T, Allocator> | Shared | Yes (atomic) | arc.h |
ArcWeak<T, Allocator> | Weak observer for Arc | Yes (atomic) | arc.h |
NonNull<T> | Non-owning, non-null | No | nonnull.h |
The XPP_MT / shared.h abstraction was removed: the library's internals
(Bytes, channels, promise state) always use Arc<T> (atomic refcount).
Rc<T> remains for user code that wants explicit zero-atomic-overhead
single-threaded sharing.
All owning types default to GlobalAllocator and
accept a custom Allocator template parameter. Empty allocators (like
GlobalAllocator) incur zero storage overhead via EBO.
Key Design Choices
- Single pointer storage:
sizeof == sizeof(T*)for all types. No two-wordshared_ptrlayout. - Niche-optimized
Option:Option<Arc<T>>andOption<Rc<T>>are alsosizeof(T*)—nullptr = None. - Non-intrusive:
RcInner<T, Allocator> = { strong, weak, value, alloc }in a single heap allocation. T doesn't inherit anything. - Rust-style refcount: weak count includes +1 for "all strongs as one weak".
weak_count()subtracts this to match Rust semantics. - Allocator protocol:
Allocatorparameter (defaultGlobalAllocator) controls allocation/deallocation. Stored in control block (Arc/Rc) or viaCompressedPair(Own/Box) with EBO. See Allocator. - Arc memory orders:
relaxedfor clone,releasefor drop,acquirefence only when count hits 0. Matches Rust libstd / triomphe / boost.
Covariant Up-cast
Rc<Derived, Allocator> → Rc<Base, Allocator> and Arc<Derived, Allocator> → Arc<Base, Allocator> work via covariant constructors (copy and move). Same Allocator required.
What xpp Has That STL Doesn't
Niche-Optimized Option<T>
The single biggest practical win. In xpp, Option<Own<T>>, Option<Box<T>>, Option<Rc<T>>, Option<Arc<T>>, and Option<NonNull<T>> all have sizeof == sizeof(T*). The None state is encoded via the null pointer — a value that normal construction never produces.
// STL: 16 bytes (8-byte pointer + bool + alignment padding)
std::optional<std::unique_ptr<int>> parent;
// xpp: 8 bytes — same size as a raw pointer
Option<Own<Node>> parent;
In tree, graph, or AST data structures where every node has an Option<parent> / Option<child>, this saves 8+ bytes per field. A million-node tree saves ~8 MB just on the parent edge alone.
Why STL can't do this: std::optional must be generic over all types, and std::unique_ptr(nullptr) is a valid (non-empty) state. xpp's smart pointers have a constructor-level invariant that null is unreachable — the type system guarantees a stored null pointer means None.
Non-Null by Default — Box<T>
STL has no equivalent. std::unique_ptr default-constructs to null, forcing null checks at every use site. Box<T> has no default constructor — if you have one, it owns a valid object. The compiler enforces this.
// STL: always defensive
void process(std::unique_ptr<Widget> w) {
if (!w) return; // ← who knows what the caller passed
w->do_thing();
}
// xpp: type system gives the guarantee
void process(Box<Widget> w) {
w->do_thing(); // ← never null, compiler-checked
}
Combined with Option<Box<T>>, you get explicit opt-in nullability at zero space cost — exactly Rust's model.
Single-Threaded Rc<T> (No Atomic Overhead)
std::shared_ptr's control block is always atomic, even when you know you're single-threaded. Every copy and destroy pays the memory barrier. xpp splits this into two types:
Rc<T>— plainintrefcount, zero atomic overhead, for event-loop or single-thread codeArc<T>— atomic refcount, for cross-thread sharing
In the dominant xpp use case (single-thread event loops), Rc<T> avoids all shared_ptr's atomic penalties.
Semantic Layering — Pick the Right Tool
| Need | STL gives you | xpp gives you |
|---|---|---|
| Maybe-null, unique ownership | unique_ptr<T> | Own<T> |
| Never-null, unique ownership | — | Box<T> |
| Maybe-null, shared ownership | shared_ptr<T> | Rc<T> or Arc<T> |
| Maybe-null, non-owning observer | weak_ptr<T> | Weak<T> or ArcWeak<T> |
| Never-null, non-owning pointer | raw T* | NonNull<T> |
Box<T> and NonNull<T> have no STL counterpart — they encode non-null guarantees in the type system that raw pointers and unique_ptr leave to convention.
Promise Ecosystem Integration
xpp smart pointers compose directly with Promise<T> chains — no glue code:
Promise<Own<Data>> fetch() {
return Promise<void>::after(100).then([]() {
return Own<Data>(new Data{42}); // Own flows through then()
});
}
// Own → Box: take ownership, guarantee non-null downstream
auto boxed = fetch().await()
.into_nonnull() // Option<Box<Data>>
.unwrap(); // Box<Data>
Single-Pointer Layout for All Types
All xpp smart pointers are sizeof(T*). Rc<T> and Arc<T> point directly to a co-located RcInner { strong, weak, T } block — single allocation, single pointer. std::shared_ptr is two pointers (object + control block), doubling stack/struct footprint.
Comparison with std
| Feature | xpp | std |
|---|---|---|
sizeof (unique) | sizeof(T*) | sizeof(T*) |
sizeof (shared) | sizeof(T*) | 2 × sizeof(T*) |
| Non-null default | Box<T> | — |
| Niche Option | Yes (nullptr = None) | No |
| Single-thread shared | Rc<T> (no atomics) | shared_ptr (always atomic) |
| Thread-safe shared | Arc<T> | shared_ptr |
| Custom allocator | Yes (Allocator template param, compile-time) | std::pmr (type-erased, runtime) |
| Allocator storage | Control block (Arc/Rc) or CompressedPair (Own/Box), EBO when empty | vtable ptr in control block (always) |
| Deallocation | ~T() + alloc.deallocate() (separated) | deleter(ptr) (single call) |
| Covariant upcast | Implicit (same Allocator) | Implicit |
| Weak observer | Weak<T> / ArcWeak<T> | weak_ptr<T> |
| Promise interop | Native (.then(), into_nonnull()) | N/A |
| Control block | Co-located (single alloc) | Separate or intrusive |
| Header-only | Yes | Yes |
own.h — Nullable Owning Smart Pointer
Introduction
own.h provides Own<T, Allocator>, a move-only nullable owning smart pointer. It is the libxpp counterpart of std::unique_ptr<T>, with a Rust-style API surface and first-class integration with Box<T> and Option<Box<T>>.
The design bridges two worlds:
- Rust-like ownership —
take()releases ownership (likeOption::take),into_nonnull()converts toOption<Box<T>>for combinator usage. - C++ RAII —
reset(),release(),operator*,operator->,get(),operator boolall work as expected.
At rest, Own<T> is sizeof(T*) when using the default GlobalAllocator (empty-base optimization eliminates the allocator storage).
Design Philosophy
-
Nullable by default.
Own<T>can be null — default-constructed, moved-from, or assignednullptr. Useif (own)to check. If you need a type-level guarantee of non-null, useBox<T>directly. -
Box<T> as the foundation.
Own<T>is implemented asOption<Box<T, Allocator>>. TheBox<T>type is non-null by construction; wrapping it inOptionadds the null state. This means allOwn<T>operations ultimately delegate toBox<T>for resource management. -
Allocator via EBO. The default
GlobalAllocatoris an empty class. C++ empty-base optimization (EBO) collapses it to zero size, sosizeof(Own<T>) == sizeof(T*). Stateful allocators add their own size. -
Covariant construction.
Own<Derived, Allocator>implicitly converts toOwn<Base, Allocator>(if the pointer is convertible). SameAllocatorrequired — differentAllocatortypes would have differentCompressedPairlayouts. -
Void specialization.
Own<void>stores a raw pointer withoutoperator*oroperator->. Useful for opaque handles where onlyreset()andget()matter. SFINAE removes the dereference operators whenT = void. The destructor callsdeallocate(ptr, Layout{0,1})(no~T()for void).
Architecture
classDiagram
class Own {
-OptionBox m_inner
+Own() = default
+Own(T* p)
+Own(T* p, Deleter d)
+Own(Box nn)
+Own(OptionBox opt)
+reset(T* p)
+take() T*
+release() T*
+get() T*
+operator*() T
+operator->() T*
+operator bool()
+into_nonnull() OptionBox
}
class Box {
<<non-null>>
+from_raw(T*)
+try_from_raw(T*) Option
+into_raw() T*
+operator*() T
}
class Option {
+is_some() bool
+is_none() bool
+unwrap() T
+take() Option
}
Own *-- Box : via Option
OptionBox ..> Box
Own<T> is Option<Box<T>>. That's the whole implementation.
API Reference
Construction
| Expression | Result |
|---|---|
Own<T> o; | Empty (null) |
Own<T> o(nullptr); | Empty (null) |
Own<T> o(p); | Owns raw pointer p (null → empty) |
Own<T> o(p, alloc); | Owns p with custom allocator |
Own<T> o(std::move(box)); | Adopts from non-null Box<T> |
Own<T> o(std::move(opt)); | Adopts from Option<Box<T>> |
Own<Base, A>(std::move(derived_own)); | Covariant: Derived* → Base* (same A) |
Mutation
| Method | Description |
|---|---|
reset(T* p = nullptr) | Delete old, own new (null → empty) |
release() | Relinquish ownership, return raw pointer |
take() | Same as release() — Rust-style name |
operator=(nullptr) | Reset to empty |
Access
| Method | Returns | On empty |
|---|---|---|
get() | T* | nullptr |
operator*() | T& | Debug assert; UB in release |
operator->() | T* | Debug assert; UB in release |
operator bool() | bool (explicit) | — |
operator==(nullptr) | bool | — |
operator!=(nullptr) | bool | — |
Bridge to Rust-style
| Method | Returns | Description |
|---|---|---|
into_nonnull() | Option<Box<T, Allocator>>&& (rvalue only) | Consume Own, get nullable Box |
To access the allocator, unwrap to Box first: std::move(own).into_nonnull().unwrap().allocator(). Own itself doesn't expose allocator() because an empty Own has no inner Box and thus no allocator instance.
Usage Examples
Basic ownership
xpp::Own<Connection> conn(new Connection("localhost:8080"));
if (conn) {
conn->send("hello");
}
// Automatically deleted when `conn` goes out of scope.
Release and re-wrap
xpp::Own<Buffer> buf = allocate();
// Hand raw pointer to a C API...
c_api_process(buf.release()); // buf is now empty
// ...re-wrap the result from C API
void* raw = c_api_get_result();
buf.reset(static_cast<Buffer*>(raw));
Custom allocator
struct FileAlloc {
void deallocate(void *p, xpp::Layout) const noexcept {
if (p) fclose(static_cast<FILE*>(p));
}
};
xpp::Own<FILE, FileAlloc> file(fopen("data.txt", "r"), FileAlloc{});
// `FileAlloc::deallocate` called on destruction or reset.
// sizeof(Own<FILE, FileAlloc>) == sizeof(FILE*) + sizeof(FileAlloc) (EBO if empty)
Covariant adoption
xpp::Own<FileStream> stream = open_file_stream("data.bin");
xpp::Own<Stream> base = std::move(stream); // implicit upcast (same Allocator)
base->read(buf, len);
// Own<Base> destructor calls ~FileStream() then GlobalAllocator::deallocate.
Bridge to Option<Box<T>> for combinators
xpp::Own<int> maybe_own = compute_value();
// Convert to Option<Box<int>> and use Option combinators:
auto result = std::move(maybe_own).into_nonnull()
.map([](auto &box) { return *box * 2; })
.unwrap_or(0);
Opaque void handles
// Own<void> has no operator* or operator-> — just get/reset/release.
xpp::Own<void> handle(platform_create_window());
platform_draw(handle.get());
// ~Own<void> calls GlobalAllocator::deallocate (which calls ::operator delete).
Comparison
xpp::Own<T> | std::unique_ptr<T> | Rust Option<Box<T>> | |
|---|---|---|---|
| Nullable | Yes (default) | Yes (default) | Yes (via Option) |
| Move-only | Yes | Yes | Yes |
| Release/take | take() / release() | release() | Option::take + Box::into_raw |
| Custom allocator | Allocator template param | Deleter template param | A: Allocator |
| Allocator storage | CompressedPair (EBO when empty) | EBO (empty-base optimization) | In Box (ZST = 0 bytes) |
| Deallocation | ~T() + alloc.deallocate() (separated) | deleter(ptr) (single call) | drop + dealloc |
| Covariant | Own<Derived, A> → Own<Base, A> (same A) | unique_ptr<Derived, D> → unique_ptr<Base, D> | Via trait objects only |
| Into Rust path | into_nonnull() -> Option<Box<T>> | N/A | Built-in |
| Void support | Yes (SFINAE on * / ->) | Yes (specialization) | Box<dyn Any> |
| Size (default) | sizeof(T*) | sizeof(T*) | sizeof(T*) |
| Debug assert on null deref | Yes (XPP_DEBUG_ASSERT) | No (UB) | Panic |
Implementation Notes
Storage
template <class T, class Allocator = GlobalAllocator>
class Own {
using Inner = Option<Box<T, Allocator>>;
Inner m_inner;
};
Own<T> is a thin wrapper around Option<Box<T, Allocator>>. Every operation maps to a corresponding Option or Box operation:
| Own operation | Underlying |
|---|---|
Own(T* p) | Box<T>::try_from_raw(p) → Option<Box<T>> |
reset(p) | m_inner = Box<T>::try_from_raw(p) |
take() | m_inner.take().unwrap_unchecked().into_raw() |
operator*() | m_inner.unwrap_unchecked().operator*() |
if (own) | m_inner.is_some() |
into_nonnull() | std::move(m_inner) |
Empty-Base Optimization
static_assert(sizeof(Own<int>) == sizeof(int*),
"Own<T, GlobalAllocator> must be sizeof(T*)");
GlobalAllocator is stateless (empty class). The compiler applies EBO via CompressedPair, so Box<T, GlobalAllocator> has no extra storage cost beyond T*, and Option<Box<T>> (which uses aligned_storage) also collapses to sizeof(T*).
Stateful allocators (custom allocators with members) add their size on top. If the allocator itself is empty (stateless struct), EBO applies again — sizeof(Own<T, EmptyAlloc>) == sizeof(T*).
Destruction
The destructor calls ~T() explicitly, then alloc.deallocate(ptr, Layout::of<T>()) — separating object destruction from memory deallocation, matching Rust's Allocator trait. For T = void, ~T() is skipped (tag dispatch) and Layout{0, 1} is used as a sentinel.
SFINAE on operator* / operator->
template <class U = T,
class = typename std::enable_if<!std::is_void<U>::value>::type>
U& operator*() const noexcept;
When T = void, the enable_if fails and the compiler removes the overload from the candidate set. This avoids hard errors while keeping the API clean — Own<void> simply doesn't expose dereference.
Covariance
The covariant constructor uses a friend declaration to access m_inner of another instantiation:
template <class U,
class = typename std::enable_if<
std::is_convertible<U*, T*>::value &&
!std::is_same<U, T>::value>::type>
Own(Own<U, Allocator> &&other) noexcept : m_inner(std::move(other.m_inner)) {}
template <class, class> friend class Own;
Two constraints gate the conversion: pointer convertibility (Derived* → Base*) and non-identity (not OwnAllocator required — different Allocator types would have different CompressedPair layouts. The friend declaration is necessary because Own<U, Allocator>::m_inner is private to that instantiation.
Default vs debug
operator* and operator-> only check in debug builds (XPP_DEBUG_ASSERT). This is intentional: the cost of a null check on every pointer dereference is rarely acceptable in release builds. The contract is that callers must ensure non-null via if (own) or structural proof before dereferencing — exactly the same contract as unique_ptr::operator*.
box.h — Non-Null Owning Smart Pointer
Introduction
box.h provides Box<T, Allocator>, a non-null owning smart pointer with a Rust-style API. Unlike Own<T> which is nullable, Box<T> is guaranteed non-null by construction — no default constructor, no reset(), no null state.
A partial specialization Option<Box<T, Allocator>> enables niche optimization: nullptr encodes None, so sizeof(Option<Box<T>>) == sizeof(T*) — matching Rust's Option<Box<T>>.
Design Philosophy
-
Non-null at the type level.
Box<T>deletes the default constructor. Construction from a null raw pointer panics in debug. This eliminates an entire class of null-dereference bugs. -
EBO via CompressedPair.
CompressedPair<T, Allocator>uses private inheritance for empty allocators to achieve zero storage overhead —sizeof(Box<T, GlobalAllocator>) == sizeof(T*). -
Niche optimization for Option<Box<T>>. The
Option<Box<T>>specialization stores a singleCompressedPair;nullptrmeansNone. No bool tag, no wasted bytes — matches Rust exactly. -
Covariant construction.
Box<Derived, A>implicitly moves intoBox<Base, A>(andOption<Box<Base, A>>) when the pointer and allocator are convertible — matchingstd::unique_ptr's behavior. -
Move-only with a "husk" state. Post-move, the source holds
nullptrinternally. This violates the public invariant but is hidden — the only valid operation on a moved-fromBoxis destruction (which guards on null). This matchesstd::unique_ptr's post-move contract.
Architecture
graph TD
subgraph "User API"
BOX["Box<T, D>"]
FROM_RAW["from_raw(p, d)"]
TRY_FROM_RAW["try_from_raw(p, d) → Option"]
INTO_RAW["into_raw()"]
OPT_BOX["Option<Box<T, D>>"]
end
subgraph "Storage"
CP["CompressedPair<T*, D>"]
EBO["Empty allocator → inherit<br/>Stateful → member"]
end
subgraph "Related Types"
NN["NonNull<T>"]
OWN["Own<T, D>"]
OPT["Option<T>"]
end
BOX --> CP
CP --> EBO
OPT_BOX --> CP
BOX --> FROM_RAW
BOX --> TRY_FROM_RAW
BOX --> INTO_RAW
BOX --> NN
OPT_BOX --> OWN
OPT_BOX --> OPT
API Reference
Box<T, Allocator>
| Member | Description |
|---|---|
static from_raw(T*, Allocator) | Wrap raw pointer. Debug-asserts non-null. |
static try_from_raw(T*, Allocator) | Checked: returns Option<Box> (None if null). |
T* get() | Raw pointer access. |
T& operator*() | Dereference (SFINAE-removed for T = void). |
T* operator->() | Member access (SFINAE-removed for T = void). |
Allocator& allocator() | Access the allocator. |
NonNull<T> as_nonnull() | Non-owning non-null view. |
T* into_raw() && | Relinquish ownership (consuming, rvalue only). |
Deleted: default ctor, copy ctor, copy assignment.
Option<Box<T, Allocator>>
Asymmetric unwrap(): const& returns T* (borrow), && returns Box<T> (consume). Combinators pass NonNull<T> to callbacks on const& and Box<T>&& on &&.
| Member | Returns const& | Returns && |
|---|---|---|
unwrap() | T* | Box<T> |
unwrap_unchecked() | T* | Box<T> |
map(fn) | Option<U> | Option<U> |
and_then(fn) | Option<R> | Option<R> |
filter(pred) | — | Option<Box<T>> |
Usage Examples
Basic ownership
auto raw = new Connection("localhost:8080");
auto box = xpp::Box<Connection>::from_raw(raw);
box->send("hello");
// box.into_raw() release; or let destructor run
Checked construction (nullable source)
Connection* maybe = pool.acquire();
auto opt = xpp::Box<Connection>::try_from_raw(maybe);
opt.inspect([](auto conn) { conn->send("acquired"); });
// If maybe was null, opt is None — no panic, no UB.
Covariant move
xpp::Box<FileStream> derived = xpp::Box<FileStream>::from_raw(new FileStream);
xpp::Box<Stream> base = std::move(derived); // implicit upcast
Option<Box> combinators
auto maybe_box = xpp::Box<int>::try_from_raw(raw);
auto doubled = std::move(maybe_box)
.map([](auto nn) { return *nn * 2; }) // nn is NonNull<int>
.unwrap_or(0);
Custom allocator
struct FreeAlloc {
void operator()(void* p) const noexcept { free(p); }
};
void* buf = malloc(4096);
auto box = xpp::Box<void, FreeAlloc>::from_raw(
buf, FreeAlloc{});
// sizeof(Box<void, FreeAlloc>) == sizeof(void*) (FreeAlloc is empty → EBO)
Compile-Time Size Guarantees
static_assert(sizeof(Box<int>) == sizeof(int*));
static_assert(sizeof(Option<Box<int>>) == sizeof(int*));
Comparison
| Feature | xpp::Box<T> | std::unique_ptr<T> | Rust Box<T> |
|---|---|---|---|
| sizeof | sizeof(T*) | sizeof(T*) (default deleter) | sizeof(T*) |
| Non-null | Guaranteed (no default ctor) | Nullable (default ctor) | Guaranteed |
| Move-only | Yes | Yes | Yes |
| Custom allocator | Allocator template param | Deleter template param | A: Allocator |
| Allocator storage | CompressedPair (EBO when empty) | EBO (empty-base optimization) | In Box (ZST = 0 bytes) |
| Deallocation | ~T() + alloc.deallocate() (separated) | deleter(ptr) (single call) | drop + dealloc |
| Covariant | Box<Derived, A> → Box<Base, A> (same A) | unique_ptr<Derived, D> → unique_ptr<Base, D> | Via DerefMut trait |
| Niche Option | Yes (Option<Box<T>> = ptr) | No | Option<Box<T>> = ptr |
| EBO | Yes (CompressedPair) | Via empty-base optimization | N/A (ZST, no EBO needed) |
| Post-move | nullptr husk (dtor guards) | nullptr (dtor guards) | Consumed (no husk) |
Implementation Notes
CompressedPair
Two specializations based on whether the allocator is empty and non-final:
// Empty + non-final → inherit privately (EBO)
template <class T, class D> struct CompressedPair<T, D, true> : private D {
T* p;
};
// Stateful → store as member
template <class T, class D> struct CompressedPair<T, D, false> {
T* p;
D d;
};
__is_final detection supports C++11 toolchains that lack std::is_final (C++14). On truly ancient toolchains, the check degrades to "assume not final" — a size-not-correctness issue.
Option<Box> Niche Optimization
template <class T, class Allocator>
class Option<Box<T, Allocator>> {
CompressedPair<T, Allocator> m_storage;
};
Option<Box<T>> stores the same CompressedPair<T*, Allocator> as Box<T>. nullptr in m_storage.p represents None. Since Box guarantees non-null, nullptr is free to repurpose. No bool tag — sizeof(Option<Box<int>>) == sizeof(int*).
The asymmetric unwrap() is necessary because Box is move-only: const& cannot move out, so it returns T* (a borrow). && consumes the Option and returns Box<T> by move.
Post-move husk
After Box(Box&&) or Option<Box>(Box&&), the source's m_storage.p is set to nullptr. The destructor guards:
~Box() {
if (m_storage.p) m_storage.allocator()(m_storage.p);
}
This allows the defaulted move operations (no custom cleanup needed for the source) while keeping size minimal.
rc.h / weak.h — Single-Threaded Reference Counting
Introduction
rc.h provides Rc<T, Allocator>, a non-null shared-owning reference-counted handle to a heap-allocated T. weak.h provides Weak<T, Allocator>, a non-owning observer that does not keep T alive. Together they form a single-threaded ownership system with explicit cycle-breaking — the same design as Rust's std::rc::Rc<T, Allocator> + std::rc::Weak<T, Allocator>.
Key properties:
| Property | Value |
|---|---|
sizeof(Rc<T, Allocator>) | sizeof(T*) (any A — Allocator lives in RcInner, not Rc) |
sizeof(Option<Rc<T, Allocator>>) | sizeof(T*) (niche: nullptr = None) |
sizeof(Weak<T, Allocator>) | sizeof(T*) |
| Allocation | 1× per make() (inner block = counts + T + Allocator) |
| Thread safety | Single-thread only (use Arc<T, Allocator> for cross-thread) |
| Default Allocator | GlobalAllocator (empty → EBO → zero overhead) |
Rc is co-located but NOT intrusive: T does not need to inherit from anything. A single Rc<T, Allocator>::make(args...) call allocates an RcInner<T, Allocator> block that carries the strong count, weak count, the value, and the allocator instance side by side — warm cache lines and one free when the last observer leaves. See Allocator for the allocator protocol.
Architecture
graph TD
subgraph "Construction"
MAKE["Rc<T>::make(args)"]
INNER["RcInner<T> — heap (strong=1, weak=1, value)"]
MAKE --> |"single ::operator new"| INNER
end
subgraph "Ownership"
RC["Rc<T> — sizeof = T*"]
RC2["Rc<T> (clone) — strong += 1"]
W["Weak<T> — weak += 1"]
INNER --> RC
RC --> |"copy / Rc::clone"| RC2
INNER --> W
end
subgraph "Drop Path"
DROP_STRONG["Rc destroyed → strong -= 1"]
DROP_T["strong == 0 → ~T() → weak -= 1"]
DROP_WEAK["Weak destroyed → weak -= 1"]
FREE["weak == 0 → ::operator delete(inner)"]
DROP_STRONG --> DROP_T
DROP_T --> |"weak -= 1"| FREE
DROP_WEAK --> FREE
end
subgraph "Upgrade"
UPGRADE["weak.upgrade()"]
NONE["None (strong == 0)"]
SOME["Some(Rc) (strong += 1)"]
UPGRADE --> |"strong == 0"| NONE
UPGRADE --> |"strong > 0"| SOME
end
Reference-Count Layout (matches Rust)
strong = number of Rc<T> handles
weak = number of Weak<T> handles + 1 (the +1 represents "the set of all live Rcs")
When the last Rc drops:
stronghits 0 → destroyTin place- Decrement
weak(this is the "+1 for all strongs" unwinding) - If
weaknow hits 0 → free the inner block
This two-stage teardown means Weak::upgrade() can safely read strong even after every visible Rc is gone — the inner memory outlives T.
API Reference
Rc<T>
| Category | Signature | Description |
|---|---|---|
| Construct | Rc<T>::make(args...) | Single allocation: inner + T. strong=1, weak=1. |
| Copy | Rc(const Rc&) | +1 strong (implicit on copy) |
| Move | Rc(Rc&&) | Zero count change; source invalidated |
| Covariant copy | Rc<Base>(const Rc<Derived>&) | Same inner, +1 strong |
| Covariant move | Rc<Base>(Rc<Derived>&&) | Same inner, no count change |
| Clone (member) | r.clone() | Explicit +1, returns new Rc |
| Clone (static) | Rc<T>::clone(&r) | Rust-style explicit +1 |
| Downgrade | Rc<T>::downgrade(&r) → Weak<T> | Creates observer; weak += 1 |
| Deref | *r, r->field, r.get() → T&, T* | Direct access through the inner |
| Counts | r.strong_count(), r.weak_count() | Debug/instrumentation (do not branch on) |
| Swap | r.swap(other), swap(r1, r2) | Exchange inners |
Rc<T> has no default constructor — it is always non-null when valid.
Weak<T>
| Category | Signature | Description |
|---|---|---|
| Default | Weak() | Null weak (no inner observed) |
| From Rc | Weak(const Rc<T>&) | Observe; weak += 1 |
| Copy / Move | standard | Copy bumps weak; move doesn't |
| Upgrade | w.upgrade() → Option<Rc<T>> | Some if strong>0, else None |
| Counts | w.strong_count(), w.weak_count() | Debug only |
| Expired | w.is_expired() | True if strong==0 or null |
| Swap | w.swap(other), swap(w1, w2) | Exchange inners |
Option<Rc<T>> Specialization
A full specialization of Option<Rc<T>> at sizeof(T*) via the niche (nullptr = None):
| Category | Signature | Description |
|---|---|---|
| From Rc | Option(const Rc<T>&), Option(Rc<T>&&) | Some, +1 strong or move |
| Unwrap | opt.unwrap() → Rc<T> (rvalue) | Takes ownership; panics on None |
| Take | opt.take() → Rc<T> | Moves value out, leaves None |
| Check | is_some(), is_none(), operator bool | Standard Option interface |
Usage Examples
Basic lifecycle
auto r1 = Rc<std::string>::make("hello");
EXPECT_EQ(r1.strong_count(), 1);
{
auto r2 = r1; // copy — strong += 1
EXPECT_EQ(r1.strong_count(), 2);
EXPECT_EQ(*r2, "hello");
} // r2 dropped — strong -= 1
EXPECT_EQ(r1.strong_count(), 1);
Covariant upcast
struct Base { virtual ~Base() = default; };
struct Derived : Base {};
Rc<Derived> derived = Rc<Derived>::make();
Rc<Base> base = derived; // implicit covariant copy
// Both point at the same inner, strong == 2.
Option<Rc<T>> niche
Option<Rc<int>> opt = Rc<int>::make(42); // Some
EXPECT_TRUE(opt.is_some());
Rc<int> taken = std::move(opt).unwrap(); // Move out
EXPECT_TRUE(opt.is_none()); // Niche preserved
static_assert(sizeof(Option<Rc<int>>) == sizeof(int*), "niche check");
Tree with cycle-free back-edges (Rc + Weak)
struct Node {
Weak<Node> parent; // weak — does NOT keep parent alive
std::vector<Rc<Node>> children; // strong — owns children
};
Rc<Node> root = Rc<Node>::make();
Rc<Node> child = Rc<Node>::make();
root->children.push_back(child.clone());
child->parent = Rc<Node>::downgrade(root); // weak += 1, strong unchanged
// Walk up:
if (auto p = child->parent.upgrade()) {
p.unwrap()->... // parent still alive
}
// When root goes out of scope:
// root's strong → 0 → T destroyed
// root's vector destroyed → child Rc dropped
// child's strong → 0 → T destroyed → Weak::parent dtor → weak -= 1
// weak → 0 → inner freed
// No leak. ✓
Observer pattern
class Subject {
std::vector<Weak<Observer>> m_observers; // weak — doesn't own
public:
void attach(const Rc<Observer>& obs) {
m_observers.push_back(Weak<Observer>(obs));
}
void notify() {
for (auto& w : m_observers) {
if (auto obs = w.upgrade()) {
obs.unwrap()->on_event();
}
// expired entries naturally skipped; lazy cleanup possible
}
}
};
Comparison
| Feature | xpp::Rc<T> | std::shared_ptr<T> | Rust Rc<T> |
|---|---|---|---|
| sizeof | sizeof(T*) | 2× ptr (ptr + ctrl) | sizeof(T*) |
| Allocation | 1× per make | Constructor from ptr: 2×; make_shared: 1× | 1× per Rc::new |
| Thread-safe | No (plain size_t) | Yes (atomic) | No |
| Non-null by default | Yes (no default ctor) | No (default → null) | Yes (Rc::new → non-null) |
| Niche Option | Yes (Option<Rc | No | Yes (Option<Rc |
| Weak observer | Weak | weak_ptr | Weak |
| Cycle-breaking | Explicit via Weak | Explicit via weak_ptr | Explicit via Weak |
| Custom allocator | Yes (Allocator template param, in RcInner) | Yes (std::pmr, type-erased in ctrl block) | Yes (A: Allocator, in RcBox) |
| Allocator overhead | 0 bytes when empty (EBO) | 1 vtable ptr (always) | 0 bytes when ZST |
| Deallocation | ~T() + alloc.deallocate() | deleter(ptr) (single call) | drop + dealloc |
| Covariant | Rc<Derived, A> → Rc<Base, A> (same A) | converting ctor (implicit) | Via trait objects only |
Implementation Notes
Inner block layout
template <class T, class Allocator> struct RcInner {
size_t strong; // number of Rc<T, Allocator> handles
size_t weak; // number of Weak<T, Allocator> + 1 (the +1 represents all live Rcs)
T value; // co-located — warm cache
Allocator alloc; // for deallocation (EBO if empty)
};
Single allocation via alloc.allocate(Layout::of<RcInner<T, Allocator>>()), zero external control block. Used by both Rc<T, Allocator> and Weak<T, Allocator> sharing the same pointer. The Allocator is stored in RcInner (not in the handle) so sizeof(Rc<T, Allocator>) == sizeof(T*) for any A. When the last weak drops, the Allocator is moved out before deallocate frees the memory.
Two-stage destruction
Rc drop path:
rc_dec_strong(inner):
if --strong == 0:
inner->value.~T() // ① destroy T
rc_dec_weak_and_maybe_dealloc(inner) // ② drop the "+1 for all strongs"
Weak drop path:
rc_dec_weak_and_maybe_dealloc(inner):
if --weak == 0:
::operator delete(inner) // ③ free inner block
Steps ② and ③ are the same function. When no Weak ever existed, weak goes from 1→0 in the same call as ②, so the inner is freed immediately — no dangling empty block.
weak_count convention
Rc::weak_count() returns inner->weak - 1 — excludes the implicit +1. When only Rcs exist (no Weak), this reads 0, matching Rust's Rc::weak_count(). Weak::weak_count() uses the same formula when strong > 0, otherwise returns inner->weak (the +1 was already unwound when the last Rc dropped).
Option<Rc<T>> niche
The specialization stores a raw RcInner<T>* directly, with nullptr encoding None. Both Option<Rc<T>> and Rc<T> are friend class of each other so the specialization can access the private raw-pointer constructor and the unwrap() fast path avoids double-counting.
Why "Rc" not "Ref"
Ref is overloaded across C++ libraries (WebKit's WTF::Ref is intrusive; Qt's QRef is different again). The Rc spelling makes it unambiguous that this follows Rust's non-intrusive, co-located design — recognizable even without reading the header.
arc.h — Atomic Reference Counting (Thread-Safe)
Introduction
arc.h provides Arc<T, Allocator>, the thread-safe atomic counterpart of Rc<T, Allocator>. It also provides ArcWeak<T, Allocator>, a cross-thread-safe non-owning observer. Together they form the same ownership model as Rc/Weak but with std::atomic<size_t> counts and carefully chosen memory ordering — matching Rust's std::sync::Arc<T, Allocator> + std::sync::Weak<T, Allocator>.
Key properties:
| Property | Value |
|---|---|
sizeof(Arc<T, Allocator>) | sizeof(T*) (any A — Allocator lives in ArcInner, not Arc) |
sizeof(Option<Arc<T, Allocator>>) | sizeof(T*) (niche: nullptr = None) |
sizeof(ArcWeak<T, Allocator>) | sizeof(T*) |
| Allocation | 1× per make() |
| Thread safety | Yes — clone/drop/upgrade safe across threads |
| Overhead vs Rc | ~3–5× on contended core; free on uncontended cache |
| Default Allocator | GlobalAllocator (empty → EBO → zero overhead) |
Design Philosophy
-
Same shape as Rc.
Arc<T>mirrorsRc<T>in API and layout. The only difference isstd::atomic<size_t>instead of plainsize_t, plus the atomic operations with the memory order discipline below. -
Proven memory order. The acquire/release pattern is the same one used by Rust's libstd,
triomphe, andboost::atomic_shared_ptr— battle-tested across millions of crates and deployments. -
Fence-on-drop, not fence-everywhere. Clone uses
memory_order_relaxed(no synchronisation needed for mere ownership transfer). Only the thread that actually destroysTor frees the inner pays theacquirefence cost. All other threads pay onlyfetch_add/fetch_sub— the cheapest atomic RMW the hardware offers. -
CAS upgrade.
ArcWeak::upgrade()uses a compare-exchange loop rather than a check-then-increment (which would race with another thread's drop). This is the canonical lock-free pattern for weak-to-strong conversion. -
Choose Arc only when needed. If ownership never crosses threads, use
Rc<T>. Arc's atomic operations are ~3–5× slower on contended cores. The type system doesn't enforce this choice — it's a documentation and review discipline.
Architecture
graph TD
subgraph "Thread A"
A1["Arc<T>::make()"]
A2["Arc<T> clone — fetch_add(1, relaxed)"]
A3["Arc<T> drop — fetch_sub(1, release)"]
end
subgraph "Thread B"
B1["Arc<T> clone — fetch_add(1, relaxed)"]
B2["Arc<T> drop — fetch_sub(1, release)"]
B3["ArcWeak::upgrade() — CAS loop on strong"]
end
subgraph "Inner Block (heap)"
I["ArcInner<T>"]
S["strong: atomic<size_t>"]
W["weak: atomic<size_t>"]
V["value: T"]
I --> S
I --> W
I --> V
end
A1 --> |"new ArcInner"| I
A2 --> S
A3 --> S
B1 --> S
B2 --> S
B3 --> S
subgraph "Final Drop (either thread)"
D["strong.fetch_sub(1, release) == 1"]
F1["atomic_thread_fence(acquire)"]
DT["~T()"]
DW["weak -= 1 → if 0: ::operator delete"]
D --> F1
F1 --> DT
DT --> DW
end
A3 -.-> |"if last"| D
B2 -.-> |"if last"| D
Memory Order Discipline
| Operation | Ordering | Rationale |
|---|---|---|
strong.fetch_add(1) (clone) | relaxed | No synchronisation; ownership alone carries no happens-before |
strong.fetch_sub(1) (drop) | release | All previous writes to T must be visible to the destroying thread |
When strong hits 0 | atomic_thread_fence(acquire) | Pair with every prior owner's release so ~T() sees all writes |
weak.fetch_add(1) (clone) | relaxed | Same as strong clone |
weak.fetch_sub(1) (drop) | release | Pair with acquire fence in deallocator |
When weak hits 0 | atomic_thread_fence(acquire) | Pair with every prior weak drop |
ArcWeak::upgrade() CAS | acquire on success, relaxed on failure | Success must sync with prior strong drops |
API Reference
Arc<T>
Identical API to Rc<T> — all operations are atomic under the hood.
| Category | Signature | Description |
|---|---|---|
| Construct | Arc<T, Allocator>::make(args...) | Single allocation. strong=1, weak=1. SFINAE: if first arg is convertible to A, treats it as the alloc instance. |
| Construct (explicit) | Arc<T, Allocator>::make_in(alloc, args...) | Explicit allocator — no SFINAE. Use when make is ambiguous. |
| Copy | Arc(const Arc&) | +1 strong (relaxed) |
| Move | Arc(Arc&&) | Zero count change; source invalidated |
| Covariant | Arc<Base, A>(const Arc<Derived, A>&) | Same inner, +1 strong. Same A required. |
| Clone | a.clone(), Arc<T, Allocator>::clone(&a) | Explicit +1 |
| Downgrade | Arc<T, Allocator>::downgrade(&a) → ArcWeak<T, Allocator> | +1 weak, strong unchanged |
| Deref | *a, a->field, a.get() → T&, T* | Access through inner |
| Counts | a.strong_count(), a.weak_count() | Relaxed loads (do not branch on) |
| Swap | a.swap(other), swap(a1, a2) | Exchange inners |
ArcWeak<T>
| Category | Signature | Description |
|---|---|---|
| Default | ArcWeak() | Null observer |
| From Arc | ArcWeak(const Arc<T>&) | +1 weak (relaxed) |
| Copy / Move | standard | Copy bumps weak; move doesn't |
| Upgrade | w.upgrade() → Option<Arc<T>> | CAS loop. Some if strong>0, else None |
| Counts | w.strong_count(), w.weak_count() | Relaxed loads (debug only) |
| Expired | w.is_expired() | True if strong==0 or null |
| Swap | w.swap(other), swap(w1, w2) | Exchange inners |
Option<Arc<T>> Specialization
Same niche optimization as Option<Rc<T>>: nullptr = None, sizeof == sizeof(T*).
| Category | Signature | Description |
|---|---|---|
| From Arc | Option(const Arc<T>&), Option(Arc<T>&&) | Some, +1 strong or move |
| Unwrap | opt.unwrap() → Arc<T> (rvalue) | Takes ownership; panics on None |
| Take | opt.take() → Arc<T> | Moves value out, leaves None |
Usage Examples
Cross-thread ownership
auto shared = Arc<std::string>::make("hello");
std::thread t([shared]() { // copy — strong += 1 (relaxed)
EXPECT_EQ(*shared, "hello");
// shared dropped here — strong -= 1 (release)
});
t.join();
EXPECT_EQ(shared.strong_count(), 1); // only the outer handle remains
Publisher-subscriber with ArcWeak (the canonical use)
struct Subscriber {
virtual void on_event(const std::string&) = 0;
};
class Publisher {
std::mutex m_lock;
std::vector<ArcWeak<Subscriber>> m_subs;
public:
void subscribe(const Arc<Subscriber>& s) {
std::lock_guard lk(m_lock);
m_subs.push_back(Arc<Subscriber>::downgrade(s));
}
void publish(const std::string& event) {
std::lock_guard lk(m_lock);
auto write = m_subs.begin();
for (auto& w : m_subs) {
if (auto s = w.upgrade()) {
s.unwrap()->on_event(event); // safe — own a strong ref
*write++ = std::move(w);
}
// else: subscriber already dropped — skip and compact
}
m_subs.erase(write, m_subs.end());
}
};
ArcWeak::upgrade races correctly
// Thread A // Thread B
auto s = Arc<int>::make(42);
auto w = Arc<int>::downgrade(s);
// last Arc drops here:
// strong goes 1→0 → ~T()
// inner stays (weak > 0)
auto opt = w.upgrade();
// opt is None ✓ — the CAS saw strong==0 before bumping
// No use-after-free because the inner block is still live
Niche-optimized Option
static_assert(sizeof(Option<Arc<int>>) == sizeof(int*));
Option<Arc<int>> opt = Arc<int>::make(42);
Arc<int> taken = std::move(opt).unwrap(); // move out, no double-count
EXPECT_TRUE(opt.is_none());
Comparison
| Feature | xpp::Arc<T> | std::shared_ptr<T> | Rust Arc<T> |
|---|---|---|---|
| sizeof | sizeof(T*) | 2× ptr | sizeof(T*) |
| Allocation | 1× per make() | make_shared: 1×; ptr ctor: 2× | 1× per Arc::new |
| Thread-safe | Yes (atomic) | Yes (atomic) | Yes (atomic) |
| Memory order | acquire/release (explicit) | acquire/release (spec-mandated) | acquire/release |
| Niche Option | Yes | No | Yes |
| Weak observer | ArcWeak<T> | weak_ptr<T> | Weak<T> |
| Weak upgrade | CAS loop | lock() (atomic) | CAS loop |
| Custom allocator | Yes (Allocator template param, in ArcInner) | Yes (std::pmr, type-erased in ctrl block) | Yes (A: Allocator, in ArcInner) |
| Allocator overhead | 0 bytes when empty (EBO) | 1 vtable ptr (always) | 0 bytes when ZST |
| Deallocation | ~T() + alloc.deallocate() (separated) | deleter(ptr) (single call) | drop + dealloc |
| Covariant | Arc<Derived, A> → Arc<Base, A> (implicit, same A) | converting ctor (implicit) | Via trait objects only |
Implementation Notes
ArcInner layout
template <class T, class Allocator, bool UseEbo = /* is_empty<Allocator> && !is_final */>
struct ArcInner {
std::atomic<size_t> strong;
std::atomic<size_t> weak;
T value;
Allocator alloc; // non-EBO: stored as member
};
template <class T, class Allocator>
struct ArcInner<T, Allocator, /* UseEbo = */ true> : private Allocator {
std::atomic<size_t> strong;
std::atomic<size_t> weak;
T value; // EBO: Allocator inherited as base, 0 bytes
};
Same co-located design as RcInner<T, Allocator>, but with std::atomic<size_t> for the two counts. The value field itself is not atomic — it is only accessed while the caller holds an Arc<T>, which guarantees strong >= 1 for the duration of the access.
The Allocator is stored in ArcInner (not in the Arc handle) so sizeof(Arc<T, Allocator>) == sizeof(T*) for any A. When the last weak drops, the Allocator is moved out before deallocate frees the memory that contained it — required for stateful allocators that hold resources. See Allocator for details.
Why relaxed clone is correct
When thread A clones an Arc and sends it to thread B via a channel (or any synchronized handoff), the channel itself provides the happens-before — not the clone's fetch_add. The relaxed increment is therefore safe: no thread reads T until it has received the Arc through a properly synchronized channel, at which point the channel's release/acquire pair (or mutex unlock/lock) has already established visibility of all prior writes to T.
The only thread that needs acquire on the count is the one that destroys T — because it must see writes from every prior owner, and those writes were released via the counter's fetch_sub(release) on drop, not via any external channel.
CAS in upgrade()
Option<Arc<T>> upgrade() const noexcept {
if (!m_inner) return Option<Arc<T>>();
size_t s = m_inner->strong.load(relaxed);
for (;;) {
if (s == 0) return Option<Arc<T>>();
if (m_inner->strong.compare_exchange_weak(s, s+1, acquire, relaxed)) {
// ...construct Some(Arc) directly...
}
}
}
The CAS loop is necessary because between the load and the increment, another thread may drop the last Arc. A naive if (strong > 0) ++strong would bump 0→1 and resurrect a destroyed T. The CAS ensures the increment is conditional on strong still being the value we read.
Shared inner, separate Rc/Arc
Rc<T> and Arc<T> use different inner types (RcInner<T> vs ArcInner<T>) and different decrement helpers (rc_dec_strong vs arc_dec_strong). The two systems are completely separate — you cannot construct a Weak<T> from an Arc<T>, and you cannot upgrade an ArcWeak<T> into an Rc<T>. This is deliberate: mixing thread-safe and non-thread-safe counts on the same block is unsound.
Sized asserts
static_assert(sizeof(Arc<int>) == sizeof(int*));
static_assert(sizeof(Option<Arc<int>>) == sizeof(int*));
static_assert(sizeof(ArcWeak<int>) == sizeof(int*));
All three collapsed to a single pointer — no hidden allocations, no control block indirection.
nonnull.h — Non-Null Pointer Wrapper
Introduction
nonnull.h provides NonNull<T>, a guaranteed-non-null pointer wrapper with a niche-optimized Option<NonNull<T>> specialization. It is the libxpp counterpart of Rust's NonNull<T> and Option<&T> niche optimization.
sizeof(NonNull<T>) == sizeof(T*) and sizeof(Option<NonNull<T>>) == sizeof(T*) — matching Rust's layout exactly. Compared to Option<T*> (16 bytes due to the bool tag), this saves 8 bytes per slot when non-nullness can be proven at the type level.
Why Not Just T*?
A raw T* has no type-level non-null guarantee. Passing T* everywhere forces every caller to either document or check the "must not be null" contract. NonNull<T> moves the contract into the type system:
| Approach | Compile-time non-null? | Nullable via ? | Size |
|---|---|---|---|
T* | No | Check manually | 8 bytes |
Option<T*> | No | is_some() | 16 bytes |
NonNull<T> | Yes | N/A | 8 bytes |
Option<NonNull<T>> | Yes (when Some) | is_some() | 8 bytes |
API Reference
NonNull<T>
| Member | Description |
|---|---|
NonNull(T& ref) | Bind to an existing referent (always safe, SFINAE-excluded for void). |
static NonNull new_unchecked(T*) | Wrap a raw pointer; debug-asserts non-null. |
static Option<NonNull> from(T*) | Checked: nullptr → None, non-null → Some. |
T* get() | Raw pointer access (never null). |
T& operator*() | Dereference (SFINAE-removed for T = void). |
T* operator->() | Member access (SFINAE-removed for T = void). |
operator== / operator!= | Pointer equality. |
Copyable, moveable, trivially destructible.
Option<NonNull<T>>
| Method | Returns | Notes |
|---|---|---|
unwrap() | NonNull<T> (by value) | Panics if None |
unwrap_unchecked() | NonNull<T> (by value) | Debug assert only |
unwrap_or(fallback) | NonNull<T> | Fallback if None |
map(fn) | Option<U> | fn receives NonNull<T> |
and_then(fn) | R | fn returns Option<U> |
filter(pred) | Option<NonNull<T>> | Consuming (rvalue) |
inspect(fn) | Chainable | Side effect |
Usage Examples
Binding to a reference
int x = 42;
xpp::NonNull<int> p(x); // Always safe — references are non-null
*p = 10; // modifies x
Checked construction from raw pointer
int* raw = get_some_pointer_maybe_null();
auto opt = xpp::NonNull<int>::from(raw);
opt.inspect([](auto p) { *p += 1; });
Type-level contract in APIs
// Before: "widget must not be null" (documentation contract)
void draw(Widget* widget);
// After: contract enforced at compile time
void draw(xpp::NonNull<Widget> widget);
Niche-optimized Option
struct Node {
int value;
xpp::Option<xpp::NonNull<Node>> next; // 8 bytes, not 16
// nullptr = None, any other = Some
};
static_assert(sizeof(Node) == 16); // int (4+padding) + pointer (8)
Combinator chain
auto result = xpp::NonNull<Connection>::from(raw)
.map([](auto conn) { conn->send("ping"); return conn->recv(); })
.unwrap_or("timeout");
Implementation Notes
Storage
template <class T>
class NonNull {
T* m_ptr; // Invariant: m_ptr != nullptr
};
Just a raw pointer with an invariant. No runtime overhead beyond what T* already costs. Option<NonNull<T>> stores T* directly — nullptr encodes None.
Reference constructor SFINAE guards
template <class U = T,
class = typename std::enable_if<
!std::is_void<U>::value &&
std::is_same<U, T>::value>::type>
explicit NonNull(U& ref) noexcept : m_ptr(&ref) {}
Two constraints:
!std::is_void<U>—void&is ill-formed, so the constructor is removed forNonNull<void>.std::is_same<U, T>— prevents GCC from preferring this template over the implicit copy constructor when aNonNull<T>lvalue is passed by value.
Niche optimization: Option<NonNull<T>>
template <class T>
class Option<NonNull<T>> {
T* m_ptr; // nullptr = None, non-null = Some
};
Unlike the general Option<T> which uses aligned_storage + bool (16 bytes for pointers), this specialization stores only the pointer. nullptr is a niche value — NonNull guarantees m_ptr != nullptr, so nullptr is free to repurpose.
static_assert(sizeof(Option<NonNull<int>>) == sizeof(int*));
The same pattern is used by Option<Box<T>> (box.h) and Rust's Option<Box<T>> / Option<NonNull<T>> in the standard library.
result.h — Value or Error
Introduction
result.h provides Result<T, E>, a type that holds exactly one of: a success value T or an error E. It is the libxpp counterpart of Rust's Result and C++23's std::expected.
No empty state. A Result is always either Ok or Err. Accessing the wrong variant panics. The tag dispatch ok(value) / err(e) mirrors Rust's Ok(x) / Err(e) idiom for ergonomic construction.
A partial specialization Result<void, E> handles operations that succeed with no value (only an error can be produced).
Design Philosophy
-
Always holds something. No default constructor, no null state. A
Resultmust be initialized withOkorErr. -
Three unwrap trust levels.
unwrap()always checks (release too),unwrap_unchecked()only in debug,operator*()/operator->()never check — identical toOption's pattern. -
Combinators mirror Rust.
map,map_err,and_then,or_else,unwrap_or_else,inspect,inspect_err,transpose— all with the same semantics and rvalue-qualified consuming overloads. -
Void success via specialization.
Result<void, E>avoids theOkSentineldance: a zero-size tag type marks the Ok variant,operator*andoperator->are removed, and combinators accept zero-arg functions. -
Option<->Resultbridge.Option::ok_or(e)→Result<T, E>,Result::ok()→Option<T>,Result::err()→Option<E>,Result::transpose()→Option<Result<U, E>>whenT = Option<U>.
Architecture
graph TD
subgraph "User API"
OK["ok(value) → OkResult<T>"]
ERR["err(e) → ErrResult<E>"]
RESULT["Result<T, E>"]
VOID_R["Result<void, E>"]
end
subgraph "Type System"
ENUM["Enum<T, E>"]
OPTION["Option<T>"]
IS_OPT["is_option<T> trait"]
end
subgraph "Combinators"
MAP["map(fn) → Result<U, E>"]
MAP_ERR["map_err(fn) → Result<T, F>"]
AND_THEN["and_then(fn) → Result<U, E>"]
OR_ELSE["or_else(fn) → Result<T, F>"]
TRANSPOSE["transpose() → Option<Result<U, E>>"]
INSPECT["inspect / inspect_err"]
OK_OR["ok() / err() → Option"]
end
OK --> RESULT
ERR --> RESULT
RESULT --> ENUM
RESULT --> MAP
RESULT --> MAP_ERR
RESULT --> AND_THEN
RESULT --> OR_ELSE
RESULT --> TRANSPOSE
TRANSPOSE --> IS_OPT
OK_OR --> OPTION
VOID_R -.->|specialization| RESULT
API Reference
Tags and Factory Functions
| Expression | Result |
|---|---|
Result<T, E>(ok, value) | Ok with value |
Result<T, E>(err, error) | Err with error |
ok(value) or err(e) | Implicit conversion carrier |
Result<void, E>(ok) | Void success |
Result<void, E>(err, error) | Void error |
Observers
| Method | Returns | Panics if |
|---|---|---|
is_ok() | bool | Never |
is_err() | bool | Never |
operator bool() | bool (explicit) | Never |
Unwrap (checked)
| Method | Returns | Panics if |
|---|---|---|
unwrap() | T& / const T& / T&& | is_err() |
unwrap_err() | E& / const E& / E&& | is_ok() |
expect(msg) | T& (with custom message) | is_err() |
expect_err(msg) | E& (with custom message) | is_ok() |
Unwrap (unchecked — debug assert only)
| Method | Returns |
|---|---|
unwrap_unchecked() | T& |
unwrap_err_unchecked() | E& |
Convenience
| Method | Returns | Notes |
|---|---|---|
unwrap_or(fallback) | const T& / T | Fallback if Err |
unwrap_or_else(fn) | T (consuming, rvalue only) | Lazy fallback |
operator*() | T& | UB if is_err() |
operator->() | T* | UB if is_err() |
Combinators
| Method | Signature | Description |
|---|---|---|
map(fn) | Result<T, E> → Result<U, E> | Transform Ok value |
map_err(fn) | Result<T, E> → Result<T, F> | Transform Err value |
and_then(fn) | Result<T, E> → Result<U, E> | Monadic bind: fn(value) → Result |
or_else(fn) | Result<T, E> → Result<T, F> | Recover: fn(err) → Result |
inspect(fn) | Chainable side-effect on Ok | |
inspect_err(fn) | Chainable side-effect on Err | |
transpose() | Result<Option<U>, E> → Option<Result<U, E>> | Swap layers |
Conversion to Option
| Method | Returns | Notes |
|---|---|---|
ok() | Option<T> (consuming) | Some(value) if Ok, None if Err |
err() | Option<E> (consuming) | Some(error) if Err, None if Ok |
Result<void, E> Members
| Member | Notes |
|---|---|
Result<void, E>(ok) | Success, no value |
Result<void, E>(err, e) | Error |
is_ok() / is_err() | Same as Result<T, E> |
unwrap_err() | Returns E& |
map_err(fn) | Transform error type |
and_then(fn) | fn() → Result<U, E> |
or_else(fn) | fn(err) → Result<void, F> |
inspect_err(fn) | Chainable Err inspection |
Usage Examples
Basic Ok / Err
xpp::Result<int, std::string> r(xpp::ok, 42);
if (r.is_ok()) {
int val = r.unwrap(); // 42
}
xpp::Result<int, std::string> fail(xpp::err, std::string("not found"));
// fail.unwrap(); // panics: "unwrap() on Err Result"
ok() / err() factory functions
auto r = xpp::ok(42); // carrier, converts to Result<T, E>
auto e = xpp::err("nope");
xpp::Result<int, const char*> success = xpp::ok(42);
xpp::Result<int, const char*> failure = xpp::err("nope");
map + and_then chains
auto result = parse_number("42")
.map([](int x) { return x * 2; }) // 42 → 84
.and_then([](int x) {
if (x > 100) return xpp::ok(x / 2); // 84 → 42
return xpp::err("too small"); // short-circuit
});
map_err and or_else
auto result = load_config()
.map_err([](auto e) { return "config error: " + e; }) // enrich error
.or_else([](auto e) {
log_error(e);
return load_default_config(); // fallback
});
Void result (operation with no value)
xpp::Result<void, xErrno> write_result = write_to_file(path, data);
if (write_result.is_ok()) {
// success — no unwrap() needed
} else {
xErrno e = write_result.unwrap_err();
}
// Chain: only proceed if write succeeded
write_result
.and_then([] { return flush_file(); })
.inspect_err([](xErrno e) { log("write failed: %d", e); });
Transpose: swap Result<Option, E>
// lookup() returns Option<int> wrapped in Result: Result<Option<int>, E>
auto found = xpp::ok(xpp::some(42)); // Result<Option<int>, E>
auto transposed = std::move(found).transpose(); // Option<Result<int, E>>
// transposed == some(Ok(42))
auto not_found = xpp::ok(xpp::Option<int>(xpp::none));
auto transposed2 = std::move(not_found).transpose();
// transposed2 == None (no error, just no value)
Comparison
xpp::Result<T, E> | Rust Result<T, E> | C++23 std::expected<T, E> | |
|---|---|---|---|
| Empty state | None | None | None |
unwrap() panics | Always (release too) | Always | Throws bad_expected_access |
ok() / err() factory | ok(v) / err(e) | Ok(v) / Err(e) | std::unexpected(e) |
map_err | Yes | Result::map_err | expected::transform_error |
| Combinator set | Full (and_then, or_else, etc.) | Full | Partial (and_then, or_else, transform) |
transpose() | Yes | Yes | No |
| Void specialization | Yes (Result<void, E>) | Yes (Result<(), E>) | Yes (expected<void, E>) |
| C++ standard | C++11 | — | C++23 |
Implementation Notes
Storage: Enum<T, E>
template <class T, typename E>
class Result {
Enum<T, E> m_data;
};
Result delegates all storage and destruction to Enum<T, E>. The Ok variant is index 0, Err is index 1. is_ok() is m_data.index() == 0.
Void Specialization
template <class E>
class Result<void, E> {
Enum<OkSentinel, E> m_data;
};
OkSentinel is a zero-size tag struct. The Enum<OkSentinel, E> stores OkSentinel at index 0 and E at index 1 — no storage overhead for the success case. is_ok() checks m_data.is<OkSentinel>() (index 0), is_err() checks m_data.is<E>() (index 1).
Option Bridge: ok_or / ok_or_else
The definitions live in result.h (not option.h) because they depend on Result<T, E> being complete:
template <class T> template <class E>
Result<T, E> Option<T>::ok_or(E e) && {
return m_has_value ? Result<T, E>(ok, std::move(unwrap_unchecked()))
: Result<T, E>(err, std::move(e));
}
Option forward-declares Result, and the out-of-line definitions after Result's class body link the two. This avoids a circular header dependency.
option.h — Nullable Values with Combinators
Introduction
option.h provides Option<T>, a type-safe alternative to nullable pointers and std::optional. It closely mirrors Rust's Option<T> — both the owning Option<T> (heap-allocated or value-semantic) and the non-owning Option<T&> (zero-overhead nullable reference) — with the full set of monadic combinators.
Two key design rules separate it from std::optional:
- No implicit conversion to bool. Use
is_some()/is_none()orif (o)for explicit checking. - Abort on unwrap of None.
unwrap()always checks, even in release builds.unwrap_unchecked()skips the check but debug-asserts. There is no "undefined value" path — either you handle None, or you crash cleanly.
Design Philosophy
-
Storage is placement-new, not union. The value lives in
std::aligned_storage<sizeof(T), alignof(T)>with explicit construction/destruction. This avoids the limitations of C++11's restricted union support and works uniformly for non-default-constructible, non-copyable, and non-movable types (with appropriate usage). -
Three unwrap strategies for three trust levels.
unwrap()— always checks. Use when the invariant is not locally obvious.expect(msg)— always checks with a custom panic message. Use when the failure reason is domain-specific.unwrap_unchecked()— debug-asserts only, zero-cost in release. Use when the caller has provenis_some()structurally (e.g. afterif (o)).
-
Combinators are consuming where Rust is consuming.
filter(),ok_or(),ok_or_else(), andunwrap_or_else()are rvalue-qualified (&&), matching Rust'sselfsemantics. This prevents accidental use-after-move and makes ownership transfer explicit in the type system. -
Option<T&> is a first-class specialization.
sizeof(Option<T&>) == sizeof(T*). It's rebindable (unlike real C++ references), supports most combinators, and replaces rawT*with "might be null" semantics everywhere. -
Bridge to Result.
ok_or(err)andok_or_else(fn)convertOption<T>→Result<T, E>, enabling smooth transitions between "value might be missing" and "value or error" code paths.
Architecture
classDiagram
class Option {
-bool m_has_value
-aligned_storage m_storage
+is_some() bool
+is_none() bool
+unwrap() T
+unwrap_unchecked() T
+expect(msg) T
+unwrap_or(fallback) T
+take() Option
+map(fn) Option
+and_then(fn) Option
+or_else(fn) Option
+unwrap_or_else(fn) T
+filter(pred) Option
+inspect(fn) Option
+ok_or(err) Result
+ok_or_else(fn) Result
}
class None {
<<tag>>
}
class Some {
<<factory>>
}
Option ..> None : "constructed from"
Option ..> Some : "constructed via"
The two variants at a glance:
Option<T> | Option<T&> | |
|---|---|---|
| Storage | Inline aligned_storage | Raw pointer T* |
| Size | sizeof(T) + padding | sizeof(T*) |
| Owns value | Yes | No |
| Rebindable | Via operator= | Via operator= |
| Combinators | All | All except ok_or, unwrap_or_else |
API Reference
Construction
| Expression | Result |
|---|---|
Option<T> o; | Empty (None) |
Option<T> o(none); | Empty (None) |
Option<T> o(v); | Holds copy of v |
Option<T> o(std::move(v)); | Holds moved v |
auto o = some(v); | Deduces Option<decay_t<T>> |
o = none; | Clears held value |
Observers
| Method | Returns | On None |
|---|---|---|
is_some() | bool | — |
is_none() | bool | — |
operator bool() | bool (explicit) | — |
Unwrap
| Method | Returns | On None |
|---|---|---|
unwrap() | T& / const T& / T&& | XPP_ASSERT abort |
unwrap_unchecked() | T& / const T& / T&& | Debug assert; UB in release |
expect(msg) | T& / const T& / T&& | XPP_ASSERT with msg |
unwrap_or(v) | T (by value) | Returns v |
Combinators
| Method | Signature | Semantics |
|---|---|---|
take() | () -> Option<T> | Extract value, leave None |
map(fn) | (T -> U) -> Option<U> | Transform if Some |
and_then(fn) | (T -> Option<U>) -> Option<U> | Monadic bind |
or_else(fn) | (() -> Option<T>) -> Option<T> | Fallback if None |
unwrap_or_else(fn) | (() -> T) -> T (rvalue only) | Lazy fallback |
filter(pred) | (T -> bool) -> Option<T> (rvalue only) | Keep if predicate true |
inspect(fn) | (T -> void) -> Option<T>& | Side-effect, chainable |
ok_or(e) | (E) -> Result<T, E> (rvalue only) | Convert to Result |
ok_or_else(fn) | (() -> E) -> Result<T, E> (rvalue only) | Lazy error |
Option<T&> Specifics
- No
unwrap_or_else— returning a reference to a stack local would dangle. - No
ok_or/ok_or_else— Result does not have a reference specialization. filteris const-qualified (not rvalue-only) — references are trivially copyable.take()returnsOption<T&>pointing to the same object; original is cleared.
Usage Examples
Basic value presence
xpp::Option<int> maybe = get_value();
if (maybe) {
process(maybe.unwrap());
} else {
use_default();
}
// Or with combinators:
auto result = maybe.map([](int x) { return x * 2; })
.unwrap_or(0);
Short-circuit with and_then
xpp::Option<User> user = find_user(id);
xpp::Option<Order> order = user.and_then([](User &u) {
return u.last_order();
});
// Only runs if user was Some AND last_order() was Some.
Fallback chain with or_else
auto config = read_file("local.conf")
.or_else([] { return read_file("global.conf"); })
.or_else([] { return read_file("/etc/defaults.conf"); });
Lazy default with unwrap_or_else
// compute_default() is only called if `maybe` is None.
int value = std::move(maybe).unwrap_or_else([] { return compute_default(); });
Side-effect inspection in a chain
auto result = parse(input)
.inspect([](auto &v) { log("parsed: {}", v); }) // log if Some
.map([](auto v) { return transform(v); });
Filter with predicate
auto positive = std::move(maybe).filter([](int x) { return x > 0; });
// positive is None if maybe was None OR value <= 0.
Nullable references (no heap, no copy)
// Find returns Option<T&> — zero allocation, pointer-sized.
auto found = registry.find(key);
if (found) {
found.unwrap().update(); // mutates the registry entry directly
}
// Rebindable:
int a = 1, b = 2;
Option<int&> ref(a);
ref = Option<int&>(b); // now points to b
ref.unwrap() = 99; // b becomes 99, a unchanged
Bridge to Result
auto r = std::move(maybe_user).ok_or<std::string>("user not found");
// Result<User, string>: Ok(user) or Err("user not found")
// Lazy error:
auto r2 = std::move(maybe).ok_or_else([&] {
return format_error("missing key: {}", key);
});
Comparison
xpp::Option<T> | std::optional<T> | Rust Option<T> | |
|---|---|---|---|
| Empty check | is_some() / is_none() | has_value() | is_some() / is_none() |
| Unwrap | unwrap() — always checks | value() — throws bad_optional_access | unwrap() — panics |
| Unchecked | unwrap_unchecked() | operator* — UB | unwrap_unchecked() — UB |
| Map | map(fn) | transform(fn) (C++23) | map(fn) |
| AndThen | and_then(fn) | and_then(fn) (C++23) | and_then(fn) |
| OrElse | or_else(fn) | or_else(fn) (C++23) | or_else(fn) |
| Filter | filter(pred) | ✗ | filter(pred) |
| Inspect | inspect(fn) | ✗ | inspect(fn) |
| OkOr | ok_or(e) | ✗ | ok_or(e) |
| Nullable ref | Option<T&> | ✗ | Built into borrow checker |
| Size (ref) | sizeof(T*) | N/A | N/A |
Implementation Notes
Storage
bool m_has_value = false;
typename std::aligned_storage<sizeof(T), alignof(T)>::type m_storage;
The value is constructed in-place via new (&m_storage) T(...) and destroyed via reinterpret_cast<T*>(&m_storage)->~T(). clear() is the sole destruction site, used by destructor, assignment, and operator=(none).
aligned_storage was chosen over anonymous union to support non-default-constructible types (T() = delete) in C++11 without compiler-specific extensions.
Option<T&>
Trivially a T* pointer. is_none() is m_ptr == nullptr. No heap, no lifetime management — the caller must ensure the referent outlives the Option. Rebindable via operator=, unlike native C++ references.
Combinators on rvalue vs lvalue
Consuming combinators (filter, ok_or, ok_or_else, unwrap_or_else) are &&-qualified. This enforces Rust-like ownership semantics: you move the Option, the combinator consumes it, and the original is left in a valid-but-unspecified state (typically empty). Non-consuming combinators (map, and_then, or_else, inspect) have const& and && overloads.
Bridge to Result
ok_or and ok_or_else are declared but not defined in option.h. Their definitions live in result.h via an explicit include order dependency: the user must #include <xpp/result.h> after #include <xpp/option.h>. This avoids a circular header dependency while still allowing Option to name Result via a forward declaration.
string.h — UTF-8 String
Introduction
string.h provides xpp::String, a heap-allocated UTF-8 string backed by Vec<uint8_t>. It guarantees valid UTF-8 at the type level — const String& means "valid Unicode text", while Vec<uint8_t> means "opaque bytes".
Key differences from std::string:
- Validated UTF-8. Construction validates; mutation preserves the invariant.
from_utf8()returnsResulton invalid input. - Code point iteration.
chars()yieldschar32_t, decoding multi-byte sequences transparently. - Dual OOM API.
push()asserts on OOM;try_push_str()returnsResult<void, AllocError>. Optionreturns.pop()returnsOption<char32_t>— no UB on empty strings.Vec<uint8_t>storage. Shares the same allocator protocol asVec<T>, enablingsplit_off(),retain(),shrink_to_fit(), andtry_reserve().
Design Philosophy
-
Valid UTF-8 is a type-level invariant. The constructor validates;
push(),insert(), andpop()preserve it. There is no way to get invalid bytes into aStringwithoutfrom_utf8_unchecked()(which is call-by-call UB). -
Byte storage, code point interface. Internally
Vec<uint8_t>, externallychar32_t. The type system enforces the boundary —as_bytes()gives raw bytes,chars()gives decoded code points. -
Substring on code point boundaries.
substr(),truncate(),split_off(),insert(), andremove()assert that offsets land on code point boundaries — never slicing a multi-byte character in half. -
No Unicode tables in L0. Validation, encoding, and decoding use tiny state machines (~50 lines total). Normalization, case folding, and grapheme clusters belong in a future
libxpp-ext/unicodeextension. -
C++11, header-only. No
<string>dependency —Vec<uint8_t>replacesstd::vector<uint8_t>. Norequires,consteval, orif constexpr.
Architecture
classDiagram
class String {
-Vec~uint8_t~ m_bytes
+from_utf8(Vec~uint8_t~) Result~String, Utf8Error~
+from_utf8(const char*) Result~String, Utf8Error~
+from_utf8_unchecked(Vec~uint8_t~) String
+as_bytes() Span~const uint8_t~
+into_bytes() Vec~uint8_t~
+len() size_t
+char_len() size_t
+empty() bool
+chars() Chars
+push(char32_t) void
+push_str(String) void
+try_push_str(String) Result
+pop() Option~char32_t~
+substr(size_t, size_t) String
+find(String) Option~size_t~
+contains(String) bool
+starts_with(String) bool
+ends_with(String) bool
+insert(size_t, char32_t) void
+insert_str(size_t, String) void
+remove(size_t) char32_t
+truncate(size_t) void
+clear() void
+split_off(size_t) String
+replace(String, String) String
+replacen(String, String, size_t) String
+repeat(size_t) String
+trim() String
+retain(Pred) void
+operator==(String) bool
+operator<(String) bool
}
class Chars {
+operator*() char32_t
+operator++() Chars&
+count() size_t
+begin() Chars
+end() Chars
}
class Utf8Error {
+error_pos() size_t
+into_bytes() Vec~uint8_t~
}
String --> Chars : "chars() creates"
String --> Utf8Error : "from_utf8() may return"
String *-- "1" Vec~uint8_t~ : "m_bytes"
API Reference
Construction
| Expression | Result |
|---|---|
String s; | Empty, zero allocation |
String s(capacity); | Pre-allocated capacity, empty |
String::from_utf8(bytes) | Validates, takes ownership → Result<String, Utf8Error> |
String::from_utf8("hello") | C-string, copies + validates |
String::from_utf8(data, len) | Buffer, copies + validates |
String::from_utf8_unchecked(bytes) | No validation — caller guarantees valid UTF-8 |
| Copy / Move | Default — deep copy or ownership transfer |
Views
| Method | Returns | Notes |
|---|---|---|
as_bytes() | Span<const uint8_t> | O(1), no copy |
into_bytes() | Vec<uint8_t> | Consuming, O(1) move |
Length / Capacity
| Method | Returns | Notes |
|---|---|---|
len() | size_t | Byte count, O(1) |
char_len() | size_t | Code point count, O(n) |
empty() | bool | len() == 0 |
capacity() | size_t | Allocated byte capacity, O(1) |
reserve(n) | void | Assert on OOM |
try_reserve(n) | Result<void, AllocError> | Explicit error |
shrink_to_fit() | void | Assert on OOM |
try_shrink_to_fit() | Result<void, AllocError> | Explicit error |
Element Access
| Method | Returns | On Out-of-Bounds / Empty |
|---|---|---|
pop() | Option<char32_t> | Returns None if empty |
substr(offset, count) | String | Asserts on CP boundaries |
chars() | Chars | Code point iterator |
Mutation
| Method | Returns | Notes |
|---|---|---|
push(cp) | void | Encodes 1–4 bytes, asserts on OOM + invalid CP |
push_str(s) | void | Byte append, asserts on OOM |
try_push_str(s) | Result<void, AllocError> | Explicit OOM |
push_str("hi") | void | C-string convenience |
insert(byte_pos, cp) | void | O(n), CP boundary assert |
insert_str(byte_pos, s) | void | O(n) |
remove(byte_pos) | char32_t | O(n), CP boundary assert |
truncate(new_len) | void | CP boundary assert |
clear() | void | Preserves capacity |
split_off(byte_pos) | String | O(tail length), CP boundary assert |
Search
| Method | Returns | Notes |
|---|---|---|
find(pattern) | Option<size_t> | Byte-level memmem, O(n*m) |
rfind(pattern) | Option<size_t> | Reverse scan |
contains(pattern) | bool | find(p).is_some() |
starts_with(prefix) | bool | Prefix match |
ends_with(suffix) | bool | Suffix match |
Utility
| Method | Returns | Notes |
|---|---|---|
replace(from, to) | String | All occurrences, returns new String |
replacen(from, to, n) | String | Capped count |
repeat(n) | String | Concatenate n times |
trim() | String | Remove ASCII whitespace (0x09–0x0D, 0x20) |
trim_start() | String | Leading whitespace only |
trim_end() | String | Trailing whitespace only |
retain(pred) | void | Keep CPs where pred(cp) is true |
Comparison
| Method | Notes |
|---|---|
operator== / != | Byte-wise equality |
operator< | Byte-wise lexicographic |
operator==(const char*) | C-string comparison |
Usage Examples
Construction and validation
// From a C-string — copies and validates
auto r = String::from_utf8("hello世界");
if (r.is_ok()) {
String s = r.unwrap();
fmt::print("len={} chars={}\n", s.len(), s.char_len());
// → len=11 chars=7
}
// From raw bytes — validates, returns error on failure
Vec<uint8_t> bytes = read_from_network();
auto r2 = String::from_utf8(std::move(bytes));
// Unchecked — caller guarantees validity
Vec<uint8_t> data = ...;
String s = String::from_utf8_unchecked(std::move(data));
Code point iteration
String s = String::from_utf8("a你🎉").unwrap();
for (char32_t cp : s.chars()) {
fmt::print("U+{:04X}\n", static_cast<uint32_t>(cp));
}
// → U+0061, U+4F60, U+1F389
// Manual loop
auto it = s.chars();
auto end = it.end();
while (it != end) {
char32_t cp = *it;
++it;
}
Push and pop
String s;
s.push('a');
s.push(0x4F60); // 你
s.push(0x1F389); // 🎉
auto last = s.pop(); // Option<char32_t>: Some(🎉)
s.push_str(" world");
Substring and split
String s = String::from_utf8("你好世界").unwrap();
// "你好" = first 6 bytes (你=3, 好=3)
String first_two = s.substr(0, 6); // "你好"
// Split at character boundary
String tail = s.split_off(6);
// s == "你好"
// tail == "世界"
Search
String s = String::from_utf8("hello xpp world").unwrap();
auto pos = s.find(String::from_utf8("xpp").unwrap());
// pos == Some(6)
if (s.contains("xpp")) { ... }
if (s.starts_with("hello")) { ... }
if (s.ends_with("world")) { ... }
Filter with retain
String s = String::from_utf8("a1b2c3").unwrap();
// Keep only ASCII letters
s.retain([](char32_t cp) { return cp >= 'a' && cp <= 'z'; });
// s == "abc"
Comparison
xpp::String | std::string | Rust String | |
|---|---|---|---|
| Encoding | Guaranteed UTF-8 | Byte string (any encoding) | Guaranteed UTF-8 |
| Internal storage | Vec<uint8_t> (xpp allocator) | SSO + heap (std allocator) | Vec<u8> (global allocator) |
pop() | Option<char32_t> (value recovered) | pop_back() → void (value lost) | Option<char> (value recovered) |
chars() | Code point iterator → char32_t | N/A (manual decoding) | Chars iterator → char |
| Validation | from_utf8() → Result | Never | Type-level guarantee |
| OOM handling | Dual API: assert / Result | Throws std::bad_alloc | Aborts |
split_off() | Yes (reuses Vec::split_off) | No | Yes |
retain() | Yes (code-point level) | erase(remove_if(...)) | Yes |
trim() | ASCII-only (0x09-0x0D, 0x20) | N/A | Unicode whitespace |
| C++ standard | C++11 | C++98 | — |
Implementation Notes
Validation
Single-pass scan with a ~50 line state machine. Checks:
- Continuation bytes must be
10xxxxxx - Overlong encodings (e.g. U+002F encoded as 2 bytes)
- Surrogate halves (U+D800–U+DFFF)
- Beyond U+10FFFF
No lookup tables. No dependencies beyond <cstdint>.
Delegating to Vec
Since internal storage is Vec<uint8_t>, capacity methods are one-liner delegations:
size_t String::len() const noexcept { return m_bytes.len(); }
size_t String::capacity() const noexcept { return m_bytes.capacity(); }
void String::clear() { m_bytes.clear(); }
void String::reserve(size_t n) { m_bytes.reserve(n); }
split_off() delegates to Vec::split_off() with a code point boundary check. try_reserve() and try_shrink_to_fit() delegate directly.
Chars iterator
Chars is a forward input iterator. begin() returns a copy of the current position; end() returns a sentinel with m_pos == m_end. Range-for support via instance begin()/end() — no static sentinel needed.
bytes_eq helper
Comparisons use a private bytes_eq() loop instead of memcmp() to avoid macOS C++11 C library compatibility issues.
No <string> dependency
The header does not include <string>. into_std_string() is omitted — users who need std::string can construct one from as_bytes():
String s = ...;
std::string ss(reinterpret_cast<const char*>(s.as_bytes().data()), s.len());
enum.h — Tagged Union
Introduction
enum.h provides Enum<Types...>, a type-safe tagged union holding exactly one of the specified types. It is the C++11 replacement for std::variant (C++17), serving as the storage foundation for Result<T, E>.
Always holds a value — no empty/default state. The active alternative is tracked by a runtime size_t index. Accessing the wrong alternative panics.
API Reference
Construction
| Expression | Description |
|---|---|
Enum<T...>(val) | Construct from a value of one of the types. |
Enum<T...>(InPlaceIndex<N>, args...) | In-place construct the N-th alternative. |
Observers
| Method | Description |
|---|---|
is<T>() | True if holding type T. |
is<N>() | True if holding the N-th alternative. |
index() | Zero-based runtime index of the active type. |
Access (checked — panics on mismatch)
| Method | Returns |
|---|---|
get<T>() | T& / const T& / T&& |
get<N>() | Reference to the N-th type |
Access (unchecked — debug assert only)
| Method | Returns |
|---|---|
get_unchecked<T>() | T& |
get_unchecked<N>() | Reference to the N-th type |
Usage Examples
Basic usage
xpp::Enum<int, float, std::string> v(42);
assert(v.is<int>());
assert(v.index() == 0);
int x = v.get<int>(); // 42
// v.get<float>(); // panics: holding int, not float
Disambiguating duplicate types
xpp::Enum<int, int> a(xpp::InPlaceIndex<0>{}, 42); // first int
xpp::Enum<int, int> b(xpp::InPlaceIndex<1>{}, 99); // second int
assert(a.get<0>() == 42);
assert(b.get<1>() == 99);
In-place construction
xpp::Enum<int, std::string> v(
xpp::InPlaceIndex<1>{}, "hello world");
assert(v.get<std::string>() == "hello world");
Unchecked access on hot paths
if (v.is<int>()) {
// Caller has verified — skip the redundant check
process(v.template get_unchecked<int>());
}
Comparison
xpp::Enum<T...> | std::variant<T...> (C++17) | Rust enum | |
|---|---|---|---|
| Standard | C++11 | C++17 | — |
| Empty state | None | valueless_by_exception possible | None |
| Access | get<T>() / get<N>() | std::get<T>() / std::get<N>() | Pattern matching |
| Error on wrong type | Panic | std::bad_variant_access | Compile-time |
| Visit | Not exposed (internal only) | std::visit | match |
| Duplicate types | InPlaceIndex<N> disambiguation | std::in_place_index<N> | Named variants |
Implementation Notes
Storage
template <class... Types>
class Enum {
using Storage = typename std::aligned_union<0, Types...>::type;
Storage m_storage;
size_t m_index;
};
std::aligned_union provides a byte buffer sized and aligned for the largest type in Types.... The m_index field tracks which alternative is alive.
Type-to-index mapping
template <size_t I, class T, class First, class... Rest>
struct TypeIndex<I, T, First, Rest...> {
static constexpr size_t k_value =
std::is_same<T, First>::value ? I : TypeIndex<I + 1, T, Rest...>::k_value;
};
Compile-time recursive template: finds the position of T in Types.... When T appears multiple times, get<T>() returns the first match.
Visit by index (internal)
Copy, move, and destroy use a compile-time visitor dispatch (VisitByIndex) that maps the runtime m_index to a typed operation:
template <class Tuple, size_t N>
struct VisitByIndex {
template <class Fn, class Storage>
static void run(size_t i, Storage &storage, Fn &&fn) {
if (i == N - 1) {
using T = typename std::tuple_element<N - 1, Tuple>::type;
fn(reinterpret_cast<T*>(&storage));
return;
}
VisitByIndex<Tuple, N - 1>::run(i, storage, fn);
}
};
This is a linear scan (O(N)) that beats std::visit for small N (2–4 types, which covers all current use cases: Result<T, E> and Result<void, E>). For larger variants, a jump table would be faster, but libxpp doesn't need one.
Exception safety
Copy assignment uses copy-and-swap:
Enum& operator=(const Enum &o) {
if (this != &o) {
Enum tmp(o); // Copy first (may throw)
destroy(); // Only then destroy old value
m_index = tmp.m_index;
move_from(std::move(tmp)); // Move tmp's value in
}
return *this;
}
If copy_from throws, *this is left unchanged. If it succeeds, the destroy-move sequence is noexcept for moveable types, providing the strong exception-safety guarantee.
vec.h — Contiguous Growable Array
Introduction
vec.h provides Vec<T, Alloc>, a heap-allocated contiguous growable array. It replaces std::vector<T> in all xpp-owned code paths and closely mirrors Rust's std::vec::Vec in both API design and ownership semantics.
Key differences from std::vector:
- Dual API for every growth path.
push()/reserve()/resize()assert on OOM;try_push()/try_reserve()/try_resize()returnResult<void, AllocError>for explicit error handling. first()/last()returnOption<T&>. No undefined behavior on empty containers —Noneis a first-class answer.pop()returnsOption<T>. Moving the last element out, not just destroying it. The value is consumed, not lost.- Allocator-aware. Accepts an optional
Alloctemplate parameter using xpp's allocator protocol (allocate/deallocate/grow/shrink). EBO viaCompressedPairmeanssizeof(Vec<T, GlobalAllocator>)= 24 bytes (3 words).
Design Philosophy
-
Explicit OOM, no exceptions. Every growth path has two forms: a convenience form that
XPP_ASSERTs on failure (push,reserve,resize,shrink_to_fit,append), and an explicittry_*form that returnsResult<void, AllocError>. Callers choose their trust level — crash-fast in debug, or handle gracefully in production paths. -
Option for nullable access.
get(i),first(),last(), andpop()all returnOption. The type system encodes the possibility of "nothing there" without runtime assertions or undefined behavior.operator[]remains unchecked (debug-assert only) for hot-loop performance, useget()when index validity is uncertain. -
Growth: double, minimum 4. On push-without-capacity, the buffer grows by
max(capacity * 2, 4). The minimum ensures small Vecs don't thrash on reallocation (0 → 4 → 8 → 16 → ...). Growth usesdefault_grow()(allocate + memcpy + deallocate) which the allocator may override with an in-placerealloc. -
Move semantics with cleanup. Move constructor and move assignment steal the buffer and leave the source in a valid empty state (
ptr = nullptr, len = 0, cap = 0). The moved-from Vec is safely destructible and reusable. -
C++11 compatible, header-only. No
requires,consteval, orif constexpr. Template parameter defaults andCompressedPairenable zero-overhead allocator storage without C++17 features.
Architecture
classDiagram
class Vec {
-CompressedPair~RawStorage, Alloc~ m_data
+Vec()
+Vec(size_t capacity, Alloc alloc)
+Vec(const Vec&)
+Vec(Vec&&)
+~Vec()
+push(T) void
+try_push(T) Result
+push_unchecked(T) void
+pop() Option~T~
+clear() void
+truncate(size_t) void
+get(size_t) Option~T&~
+first() Option~T&~
+last() Option~T&~
+operator[]() T&
+reserve(size_t) void
+try_reserve(size_t) Result
+shrink_to_fit() void
+try_shrink_to_fit() Result
+resize(size_t, T) void
+try_resize(size_t, T) Result
+append(Vec&) void
+try_append(Vec&) Result
+split_off(size_t) Vec
+swap_remove(size_t) T
+retain(Pred) void
+begin() T*
+end() T*
+as_span() Span~T~
+data() T*
+len() size_t
+capacity() size_t
+empty() bool
}
class RawStorage {
+T* ptr
+size_t len
+size_t cap
}
class CompressedPair {
+first() RawStorage&
+second() Alloc&
}
class Alloc {
<<template param>>
+allocate(Layout) Result~Span, AllocError~
+deallocate(void*, Layout) void
+grow / shrink (optional)
}
Vec *-- CompressedPair
CompressedPair *-- RawStorage
CompressedPair *-- Alloc
The buffer at a glance:
┌──────────────────────┬──────────────────────────────┐
│ initialized │ uninitialized │
│ [0 .. len) │ [len .. capacity) │
├──────────────────────┼──────────────────────────────┤
│ T, T, T, T, T │ ???????????????????????????? │
└──────────────────────┴──────────────────────────────┘
ptr ptr + capacity
Elements in [0, len) are live objects with properly constructed T values. Elements in [len, capacity) are raw uninitialized memory — no constructors have run, no destructors will run. push() placement-news into the gap; pop() destructs the last element and decrements len.
API Reference
Construction
| Expression | Result |
|---|---|
Vec<T> v; | Empty, capacity 0, GlobalAllocator |
Vec<T> v(alloc); | Empty, custom allocator |
Vec<T> v(capacity); | Pre-allocated with GlobalAllocator |
Vec<T> v(capacity, alloc); | Pre-allocated with custom allocator |
Vec<T> v(other); | Copy — deep clones all elements |
Vec<T> v(std::move(other)); | Move — steals buffer, source becomes empty |
Capacity
| Method | Returns | Notes |
|---|---|---|
len() | size_t | Number of live elements |
capacity() | size_t | Allocated slots (>= len) |
empty() | bool | len() == 0 |
reserve(n) | void | Asserts on OOM |
try_reserve(n) | Result<void, AllocError> | Allocates space for len() + n elements |
shrink_to_fit() | void | Asserts on OOM |
try_shrink_to_fit() | Result<void, AllocError> | Releases excess capacity |
Element Access
| Method | Returns | On Out-of-Bounds / Empty |
|---|---|---|
operator[](i) | T& | Debug assert; UB in release |
get(i) | Option<T&> | Returns None |
first() | Option<T&> | Returns None if empty |
last() | Option<T&> | Returns None if empty |
data() | T* | nullptr if empty |
as_span() | Span<T> | Zero-length span if empty |
Mutation
| Method | Returns | Notes |
|---|---|---|
push(v) | void | Copy, asserts on OOM |
push(T&& v) | void | Move, asserts on OOM |
try_push(v) | Result<void, AllocError> | Copy, explicit error |
try_push(T&& v) | Result<void, AllocError> | Move, explicit error |
push_unchecked(T&& v) | void | Debug-asserts len < cap; no grow |
pop() | Option<T> | Returns None if empty; destructs element |
clear() | void | Destructs all elements, len = 0, preserves capacity |
truncate(n) | void | Destructs elements [n, len), len = n |
Bulk Operations
| Method | Returns | Notes |
|---|---|---|
resize(n, fill) | void | Asserts on OOM |
try_resize(n, fill) | Result<void, AllocError> | Grow: construct fill; shrink: truncate |
append(other) | void | Moves all elements from other, leaves it empty |
try_append(other) | Result<void, AllocError> | Explicit error variant |
split_off(at) | Vec | Moves [at, len) into a new Vec, truncates this |
swap_remove(i) | T | Replaces [i] with last element, returns old [i]; O(1) |
retain(pred) | void | Keeps elements where pred(x) is true; preserves order |
Iteration
| Method | Returns |
|---|---|
begin() | T* |
end() | T* (one past last element) |
begin() const | const T* |
end() const | const T* |
Iterators are raw pointers — compatible with C++11 range-for and STL algorithms.
Allocator Access
| Method | Returns |
|---|---|
allocator() | Alloc& |
allocator() const | const Alloc& |
Usage Examples
Basic push / pop
xpp::Vec<int> v;
v.push(42);
v.push(7);
v.push(99);
// Access
int a = v[0]; // 42
auto first = v.first(); // Option<int&>: Some(42)
auto last = v.last(); // Option<int&>: Some(99)
auto oob = v.get(100); // Option<int&>: None
// Pop
auto x = v.pop(); // Option<int>: Some(99)
// v is now {42, 7}
Empty container safety
xpp::Vec<int> empty;
assert(empty.pop().is_none()); // None, not UB
assert(empty.first().is_none()); // None, not UB
assert(empty.last().is_none()); // None, not UB
assert(empty.get(0).is_none()); // None, not UB
Explicit error handling
xpp::Vec<LargeObject> v;
auto r = v.try_push(LargeObject{...});
if (r.is_err()) {
// OOM — degrade gracefully
return xpp::err(AllocError{});
}
Reserve + unchecked push (hot path)
xpp::Vec<int> v;
v.reserve(1000); // one allocation
for (int i = 0; i < 1000; i++) {
v.push_unchecked(i); // no capacity check, no grow
}
split_off
xpp::Vec<int> v;
v.push(1); v.push(2); v.push(3); v.push(4);
auto tail = v.split_off(2);
// v == {1, 2}
// tail == {3, 4}
swap_remove (fast unordered removal)
xpp::Vec<std::string> v;
v.push("a"); v.push("b"); v.push("c");
auto removed = v.swap_remove(0); // removes "a", swaps "c" to position 0
// v == {"c", "b"} (order not preserved!)
retain (in-place filter)
xpp::Vec<int> v;
v.push(1); v.push(2); v.push(3); v.push(4);
v.retain([](int x) { return x % 2 == 0; });
// v == {2, 4}
Range-for iteration
xpp::Vec<int> v;
v.push(1); v.push(2); v.push(3);
for (auto& x : v) {
x *= 2;
}
// v == {2, 4, 6}
// Const iteration:
const auto& cv = v;
for (const auto& x : cv) {
printf("%d\n", x);
}
Copy and move
xpp::Vec<int> a;
a.push(1); a.push(2);
xpp::Vec<int> b(a); // deep copy
xpp::Vec<int> c(std::move(a)); // move: a is now empty, c owns the buffer
a = b; // copy assignment
a = std::move(b); // move assignment
Custom allocator
struct CountingAlloc {
size_t allocs = 0;
size_t frees = 0;
xpp::Result<xpp::Span<uint8_t>, xpp::AllocError>
allocate(xpp::Layout layout) const {
void* p = ::operator new(layout.size);
if (!p) return xpp::err(xpp::AllocError{});
const_cast<CountingAlloc*>(this)->allocs++;
return xpp::ok(xpp::Span<uint8_t>(static_cast<uint8_t*>(p), layout.size));
}
void deallocate(void* ptr, xpp::Layout) const noexcept {
const_cast<CountingAlloc*>(this)->frees++;
::operator delete(ptr);
}
};
CountingAlloc ca;
{
xpp::Vec<int, CountingAlloc> v(ca);
v.push(1);
v.push(2);
assert(v.allocator().allocs >= 1);
}
// ca.frees reflects deallocation
Comparison
xpp::Vec<T, Alloc> | std::vector<T, Alloc> | Rust Vec<T> | |
|---|---|---|---|
push() on OOM | XPP_ASSERT | Throws std::bad_alloc | Aborts |
| Explicit OOM | try_push() → Result | try_emplace_back (C++26) | try_reserve() + push |
pop() | Option<T> (move out) | void (destructs, value lost) | Option<T> (move out) |
get(i) | Option<T&> | — | get(i) → Option<&T> |
first() / last() | Option<T&> | front() / back() → T& (UB if empty) | first() / last() → Option<&T> |
| Allocator storage | EBO (CompressedPair) | EBO (implementation-defined) | Global only |
| Growth strategy | Double, min 4 | 2x or 1.5x (impl-defined) | Double, min 4 |
swap_remove | Yes (O(1) unordered) | No | Yes |
split_off | Yes | No | Yes |
retain | Yes | erase(remove_if(...), ...) | Yes |
| Iterator type | Raw T* | Wrapper class | Raw pointer or slice iter |
| C++ standard | C++11 | C++98 | — |
Implementation Notes
Storage: CompressedPair
template <class T, class Alloc>
class Vec {
_::CompressedPair<RawStorage, Alloc> m_data;
struct RawStorage {
T* ptr; // heap buffer
size_t len; // initialized elements
size_t cap; // allocated slots
};
};
CompressedPair applies EBO: when Alloc is empty (like GlobalAllocator), m_data is exactly RawStorage (24 bytes). When Alloc is stateful, it grows by sizeof(Alloc).
Accessor methods for CompressedPair fields
Rather than storing m_len and m_cap as reference members (which would bloat sizeof(Vec)), the implementation uses private accessor methods that return references into m_data.first():
size_t& len_() { return m_data.first().len; }
size_t& cap_() { return m_data.first().cap; }
These are inlined by the compiler — zero runtime cost, clean sizeof.
Growth: default_grow and default_shrink
grow_to() delegates to default_grow(allocator, ptr, old_layout, new_layout), which is:
- Allocate new buffer (
allocator.allocate(new_layout)) memcpyold elements to new buffer- Deallocate old buffer (
allocator.deallocate(ptr, old_layout))
try_shrink_to_fit() uses default_shrink() instead of grow_to() because grow_to() short-circuits when new_cap <= capacity() — which is always true for a shrink operation. default_shrink() unconditionally reallocates to the smaller size.
For allocators with native realloc (e.g. jemalloc, tcmalloc), overriding grow() and shrink() avoids the intermediate copy.
Placement-new construction and explicit destruction
All element construction uses placement-new:
::new (ptr() + len()) T(value); // push
::new (ptr() + len()) T(std::move(value)); // push (rvalue)
Destruction is explicit (never delete):
ptr()[i].~T(); // individual
destroy_range(begin_idx, end_idx); // batch
dealloc_buffer() calls allocator.deallocate() on the raw memory — it does NOT call destructors. The caller must have already destroyed all live elements.
No const T
A static_assert(!std::is_const<T>::value, ...) prevents Vec<const int> — a vector of immutable elements is semantically nonsensical (you can't move out of const, can't grow by copying const, etc.).
Omitted methods
into_raw_parts()/from_raw_parts()—data()+len()+capacity()already expose the same information. The consuming/reconstruction semantics add no capability in C++ (no FFI boundary to cross).first_mut()/last_mut()— C++ distinguishes mutable vs const access viaconst-qualification on the member function, not via separate method names.get_many_mut([i, j])— Requires compile-time proof that the two indices are distinct. C++ cannot express this safely without runtime checks.
Serde
Introduction
xpp::serde is a trait-based serialization framework modeled on Rust's
serde. A data type specializes Serialize<T> /
Deserialize<T> once, and any backend that satisfies the Serializer /
Deserializer concept can drive it. Backends are duck-typed (no vtable)
and template-monomorphized per call site.
Why not just call to_json() / from_json() on each type?
- Every format (JSON, binary, TOML, ...) would need its own pair of methods per type — N types × M formats = N×M hand-written conversions.
- Serde inverts this: each type implements one pair of traits, each format implements one pair of backends, and the two meet at the call site. N types + M formats instead of N×M.
- The same
XPP_SERDE(Person, (name)(age))macro works for JSON, binary, and any future backend — zero edits to user types when adding a format.
Two backends ship in-tree:
xpp::serde::json— wrapslibx/x/json/(DOM-based, human-readable)xpp::serde::bin— compact length-prefixed binary format
Quick Start
#include <xpp/serde/json.h>
#include <xpp/serde/serde.h>
#include <xpp/string.h>
struct Person {
xpp::String name;
int32_t age;
};
// One macro generates both Serialize<Person> and Deserialize<Person>.
XPP_SERDE(Person, (name), (age))
int main() {
Person p{"Alice", 30};
// Serialize to JSON (one-step: mirrors serde_json::to_string)
auto j = xpp::serde::json::to_string(p);
xpp::String json = std::move(j).unwrap();
// json == R"({"name":"Alice","age":30})"
// Deserialize back
auto d_res = xpp::serde::json::Deserializer::from_string(json);
auto d = std::move(d_res).unwrap();
auto r = xpp::serde::deserialize<Person>(d);
if (r.is_ok()) {
Person &back = r.unwrap();
// back.name == "Alice", back.age == 30
} else {
xpp::serde::Error &e = r.unwrap_err();
// e.kind, e.message
}
}
Switching to binary is one line at the call site — Person and the
XPP_SERDE macro stay untouched:
#include <xpp/serde/bin.h>
xpp::serde::bin::Serializer ser;
xpp::serde::serialize(p, ser);
xpp::Vec<uint8_t> bytes = ser.into_buffer();
auto d = xpp::serde::bin::Deserializer::from_bytes(bytes).unwrap();
auto r = xpp::serde::deserialize<Person>(d);
Trait Model
namespace xpp::serde {
template <class T> struct Serialize; // user specializes
template <class T> struct Deserialize; // user specializes
template <class T, class S>
Result<Void, Error> serialize(const T&, S&); // dispatcher
template <class T, class D>
Result<T, Error> deserialize(D&); // dispatcher
}
Serialize<T>::run(const T&, S&)writesTthroughS&.Deserialize<T>::run(D&)reads aTfromD&.- Both return
Result<T, Error>— no exceptions, no RTTI. - Call sites use
serde::serialize(v, ser)/serde::deserialize<T>(d), not the trait directly.
Built-in specializations: bool, int32_t, int64_t, uint32_t,
uint64_t, float, double, xpp::String, Option<T>, Vec<T>.
Hand-writing a specialization
The XPP_SERDE macro covers the 80% case, but understanding the
hand-written form is useful for custom logic (validation, computed
fields, third-party types). The canonical example:
struct Person {
xpp::String name;
int32_t age = 0;
};
namespace xpp::serde {
template <>
struct Serialize<Person> {
template <class S>
static Result<Void, Error> run(const Person &p, S &s) {
// 1. Open a struct scope with the type name and field count.
XPP_SERDE_TRY_VAR(scope, s.serialize_struct("Person", 2));
// 2. Emit each field by key. serde::serialize dispatches to the
// field type's own Serialize specialization.
XPP_SERDE_TRY(scope.field("name", p.name));
XPP_SERDE_TRY(scope.field("age", p.age));
// 3. Close the scope.
return scope.end();
}
};
template <>
struct Deserialize<Person> {
template <class D>
static Result<Person, Error> run(D &d) {
struct Visitor {
Result<Person, Error> visit_map(typename D::MapAccess &m) {
Person p{};
bool got_name = false, got_age = false;
while (true) {
XPP_SERDE_TRY_VAR(key, m.next_key());
if (key.is_none()) break; // end of map
const xpp::String &k = key.unwrap();
if (k == "name") {
XPP_SERDE_TRY_VAR(v, m.template next_value<xpp::String>());
p.name = std::move(v); got_name = true;
} else if (k == "age") {
XPP_SERDE_TRY_VAR(v, m.template next_value<int32_t>());
p.age = v; got_age = true;
} else {
// Unknown field — skip its value. Forward-compatible.
XPP_SERDE_TRY(m.next_value_ignored());
}
}
if (!got_name) return err(error(ErrorKind::MissingField, "missing 'name'"));
if (!got_age) return err(error(ErrorKind::MissingField, "missing 'age'"));
return ok(std::move(p));
}
};
static const char *const kFields[] = {"name", "age"};
return d.deserialize_struct("Person", kFields, 2, Visitor{});
}
};
} // namespace serde
} // namespace xpp
Key points:
XPP_SERDE_TRY(expr)propagates the error fromexprif it'sErr.XPP_SERDE_TRY_VAR(name, expr)also captures theOkvalue.- The
Visitorstruct'svisit_mapreceives aMapAccess&— callnext_key()(returnsOption<String>,Noneat end) andnext_value<T>()(returnsResult<T, Error>). - Unknown fields are skipped by default — forward-compatible. Call
next_value_ignored()to advance the cursor without parsing. - Missing required fields produce
Err(Error{ErrorKind::MissingField, ...}).
Nested types
Serialize<T> dispatches recursively, so nesting "just works" — no
special syntax. A field of type Person inside another struct calls
Serialize<Person>::run when emitted:
struct Team {
xpp::String team_name;
Person lead;
Vec<Person> members;
};
XPP_SERDE(Team, (team_name), (lead), (members))
Serializes to:
{
"team_name": "infra",
"lead": {"name": "Alice", "age": 30},
"members": [
{"name": "Bob", "age": 25},
{"name": "Carol", "age": 28}
]
}
Option<T> serializes as null (JSON) or a discriminator tag (binary)
when None; Vec<T> serializes as an array (JSON) or length-prefixed
sequence (binary). Both round-trip automatically — no macro config needed.
The XPP_SERDE Macro
The macro generates the same Serialize<T> / Deserialize<T>
specializations shown above, mechanically. Each field is paren-wrapped,
comma-separated. Max 64 fields.
XPP_SERDE(Type, (field1), (field2), ..., (fieldN))
Field Attributes
Three attributes compose inline in the field list:
struct Config {
xpp::String host;
int32_t port;
int32_t retries;
xpp::String api_key;
xpp::String internal_id;
};
XPP_SERDE(Config,
(host),
(port, XPP_FIELD_DEFAULT(port, 8080)),
(retries, XPP_FIELD_DEFAULT(retries, 3)),
(api_key, XPP_FIELD_RENAME(api_key, "apiKey")),
(internal_id, XPP_FIELD_SKIP(internal_id)))
Serializing Config{"localhost", 0, 0, "sk_123", "i_456"} produces:
{"host":"localhost","port":0,"retries":0,"apiKey":"sk_123"}
Note: internal_id is absent (SKIP), api_key is emitted as apiKey
(RENAME), and port/retries use their actual values on serialize
(DEFAULT only fills in on deserialize when the field is missing).
| Attribute | Serialize | Deserialize |
|---|---|---|
XPP_FIELD_DEFAULT(field, value) | Emits the actual field value. | If the field is absent, uses value instead. |
XPP_FIELD_RENAME(field, "jsonName") | Emits key "jsonName". | Matches key "jsonName". |
XPP_FIELD_SKIP(field) | Does not emit the field. | Does not read; keeps the default-constructed value. |
Tagged Variants (Enums)
Sum types — where a value is one of several alternatives, distinguished
by a tag — use Enum<Ts...> plus XPP_ENUM_SERDE. This covers LSP
messages, webhook payloads, GraphQL responses, and any protocol with a
type discriminator.
struct ShapeCircle { double r; };
struct ShapeSquare { double s; };
struct ShapeTriangle { double base; double height; };
// Each alternative needs its own Serialize/Deserialize too.
XPP_SERDE(ShapeCircle, (r))
XPP_SERDE(ShapeSquare, (s))
XPP_SERDE(ShapeTriangle, (base), (height))
using Shape = xpp::Enum<ShapeCircle, ShapeSquare, ShapeTriangle>;
XPP_ENUM_SERDE(Shape,
(ShapeCircle, "circle"),
(ShapeSquare, "square"),
(ShapeTriangle, "triangle"))
Strategies
| Strategy | JSON shape | When to use | Macro |
|---|---|---|---|
| External (default) | {"circle": {"r": 1.0}} | Variant name is the wrapper key. Serde's default. | XPP_ENUM_SERDE |
| Adjacent | {"tag": "circle", "content": {"r": 1.0}} | Tag and payload are separate fields in the same object. Stripe webhooks, GraphQL. | XPP_ENUM_SERDE_ADJACENT(Type, "tag", "content", ...) |
External example:
Shape c = ShapeCircle{1.0};
// JSON: {"circle":{"r":1.0}}
// Binary: [u32 tag_index=0][f64 1.0]
Adjacent example — useful for Stripe-style {"type":"...", "data":{...}}:
using AdjShape = xpp::Enum<ShapeCircle, ShapeSquare>;
XPP_ENUM_SERDE_ADJACENT(AdjShape, "tag", "content",
(ShapeCircle, "circle"),
(ShapeSquare, "square"))
// JSON: {"tag":"circle","content":{"r":1.0}}
Internal tagging ({"type":"circle","r":1.0} — tag and payload fields
flat in the same object) is just adjacent with the payload fields inlined
directly. Use XPP_ENUM_SERDE_ADJACENT with the appropriate tag_field
name.
Unknown tags on deserialize produce
Err(Error{ErrorKind::UnknownField, ...}).
Backends
- JSON — wraps
libx/x/json/, DOM-based, human-readable - Binary — compact length-prefixed little-endian format
Error Model
enum class ErrorKind {
Unexpected, // backend-specific unexpected state
Eof, // ran out of input
InvalidValue, // type mismatch, bad UTF-8, NaN/Inf, etc.
MissingField, // required struct field absent on deserialize
UnknownField, // unknown variant tag (not unknown struct field — those are skipped)
Custom, // user-thrown via Error::custom
};
struct Error {
ErrorKind kind;
xpp::String message;
};
All fallible operations return Result<T, Error>. Construct errors with
xpp::serde::error(kind, "message") or Error::custom("message").
Check r.is_ok() / r.unwrap_err().kind at the call site — no
exceptions cross the serde boundary.
Common error scenarios:
| Scenario | ErrorKind |
|---|---|
| Required field missing in JSON object | MissingField |
Unknown variant tag (e.g. "triangle" when only circle/square declared) | UnknownField |
Type mismatch (e.g. expecting i32, got string) | InvalidValue |
| Truncated binary input | Eof |
NaN/Inf in f64 | InvalidValue |
C++11 Compatibility
The framework compiles on -std=c++11 with -fno-exceptions -fno-rtti.
No use of if constexpr, fold expressions, structured bindings,
std::variant, std::optional, std::string_view, or CTAD.
Files
| File | Purpose |
|---|---|
libxpp/xpp/serde/serde.h | Traits, dispatchers, primitive specializations, concept docs |
libxpp/xpp/serde/error.h | ErrorKind + Error |
libxpp/xpp/serde/json.h | JSON backend |
libxpp/xpp/serde/bin.h | Binary backend |
libxpp/xpp/serde/macros.h | XPP_SERDE + XPP_ENUM_SERDE macros |
JSON Backend
Introduction
xpp::serde::json::Serializer and json::Deserializer wrap libx/x/json/,
providing a DOM-based JSON backend for the serde framework. The Serializer
builds an xJson tree with xJsonNew* (malloc-backed) and dumps it via
xJsonStringify. The Deserializer parses with xJsonParseCopy (arena-backed,
safe) and walks the DOM.
Link target: any TU including json.h must link xjson.
Usage
Serialize
One-step (preferred for simple cases — mirrors serde_json::to_string):
#include <xpp/serde/json.h>
Person p{"Alice", 30};
auto r = xpp::serde::json::to_string(p);
if (r.is_ok()) {
xpp::String json = std::move(r).unwrap();
// json == R"({"name":"Alice","age":30})"
}
Two-step (when you need to inspect or reuse the Serializer):
xpp::serde::json::Serializer ser;
xpp::serde::serialize(p, ser);
xpp::String json = ser.to_string();
Serializer::to_string() stringifies the internal xJson tree to a
compact JSON string. Call it after serialize() returns Ok.
Serializer::reset() clears the internal state for reuse.
Deserialize
auto d_res = xpp::serde::json::Deserializer::from_string(R"({"name":"Bob","age":25})");
if (!d_res.is_ok()) {
// Parse error (malformed JSON)
xpp::serde::Error &e = d_res.unwrap_err();
// e.kind == ErrorKind::InvalidValue, e.message has details
return;
}
auto d = std::move(d_res).unwrap();
auto r = xpp::serde::deserialize<Person>(d);
if (r.is_ok()) {
Person &p = r.unwrap();
// p.name == "Bob", p.age == 25
} else {
xpp::serde::Error &e = r.unwrap_err();
// e.g. MissingField, InvalidValue
}
Entry points:
| Method | Input |
|---|---|
Deserializer::from_string(const xpp::String&) | Owned String |
Deserializer::from_string(const char*) | C string literal |
Both parse a copy into an arena — the input does not need to outlive the
Deserializer.
Encoding
JSON is self-describing — field names are encoded as object keys, and
Option<T> uses null for None. No special discriminator bytes are
needed for Some; serialize_some is a plain forward to
serde::serialize.
Primitives
| C++ type | JSON |
|---|---|
bool | true / false |
int32_t / int64_t | number |
uint32_t / uint64_t | number |
float / double | number (NaN/Inf rejected on serialize) |
xpp::String | "..." |
Composite types
| C++ type | JSON |
|---|---|
Option<T> (None) | null |
Option<T> (Some) | value as-is (no wrapper) |
Vec<T> | [...] |
struct (via XPP_SERDE) | {"field": ...} |
Enum (external) | {"tagString": {payload}} |
Enum (adjacent) | {"tag": "tagString", "content": {payload}} |
Example — a struct with Option and Vec:
struct Group {
xpp::String name;
Option<int32_t> priority; // null if not set
Vec<xpp::String> tags;
};
XPP_SERDE(Group, (name), (priority), (tags))
Group{"infra", none, Vec<String>{"a","b"}} serializes to:
{"name":"infra","priority":null,"tags":["a","b"]}
Tagged variants
| Strategy | JSON shape |
|---|---|
| External | {"circle": {"r": 1.0}} |
| Adjacent | {"tag": "circle", "content": {"r": 1.0}} |
See the serde README for the macro declarations.
Unknown Fields
The JSON deserializer skips unknown fields by default — if the JSON
object contains keys that the Deserialize<T> visitor doesn't recognize,
they are ignored via next_value_ignored(). This makes the format
forward-compatible: adding a field to the JSON (e.g. from a newer server)
does not break older clients.
If you need strict mode (reject unknown fields), hand-write the
Deserialize<T> specialization and return Err from the else branch
instead of calling next_value_ignored().
Error Handling
All errors surface as Result<T, Error>. Common JSON-specific failures:
| Scenario | ErrorKind | Example |
|---|---|---|
| Malformed JSON at parse time | InvalidValue | from_string("{bad}") |
| Type mismatch | InvalidValue | expecting i32, got "hello" |
| Required field missing | MissingField | {"name":"X"} into a struct requiring age |
| Unknown variant tag | UnknownField | {"triangle":{...}} when only circle/square declared |
NaN/Inf in f64 | InvalidValue | serialize(1.0/0.0, ser) |
Errors are non-fatal — the Deserializer can be reused after an error
on a different input (call from_string again).
Binary Backend
Introduction
xpp::serde::bin::Serializer and bin::Deserializer provide a compact
length-prefixed binary backend. Field names are not encoded on the wire —
structs are field values back-to-back. All multi-byte integers are
little-endian.
This format is not self-describing — both ends must agree on the schema (field types and order). It is roughly 2-4x smaller than JSON for typical structs and faster to parse (no string comparisons for field names).
Usage
Serialize
#include <xpp/serde/bin.h>
#include <xpp/serde/serde.h>
Person p{"Alice", 30};
xpp::serde::bin::Serializer ser;
xpp::serde::serialize(p, ser);
xpp::Vec<uint8_t> bytes = ser.into_buffer();
// bytes contains the compact binary encoding
into_buffer() moves the internal buffer out (the Serializer is left
empty). buffer() returns a Span<const uint8_t> borrow instead — use
this when you need to inspect the bytes without taking ownership.
reset() clears the internal state for reuse.
Deserialize
auto d_res = xpp::serde::bin::Deserializer::from_bytes(bytes);
if (!d_res.is_ok()) {
// Should not happen for a valid Vec<uint8_t>, but check anyway
return;
}
auto d = std::move(d_res).unwrap();
auto r = xpp::serde::deserialize<Person>(d);
if (r.is_ok()) {
Person &p = r.unwrap();
// p.name == "Alice", p.age == 30
} else {
xpp::serde::Error &e = r.unwrap_err();
// e.g. Eof (truncated input), InvalidValue (bad UTF-8)
}
Entry points:
| Method | Input | Ownership |
|---|---|---|
Deserializer::from_bytes(const Vec<uint8_t>&) | Vec | Copies the bytes into the deserializer |
Deserializer::from_bytes(const uint8_t*, size_t) | Raw pointer + length | Copies the bytes |
Deserializer::borrow(Span<const uint8_t>) | Span | Borrows — caller must keep the buffer alive |
The Deserializer holds its own copy by default (safe). borrow is the
zero-copy escape hatch for hot paths — the caller must ensure the source
buffer outlives the Deserializer.
Wire Format
| Type | Encoding | Size |
|---|---|---|
bool | 0x00 (false) / 0x01 (true) | 1 byte |
i32 / u32 | little-endian | 4 bytes |
i64 / u64 | little-endian | 8 bytes |
f32 | IEEE 754 LE | 4 bytes |
f64 | IEEE 754 LE | 8 bytes |
String | u32 length + UTF-8 bytes (no NUL terminator) | 4 + N |
Option::None | 0x00 | 1 byte |
Option::Some(v) | 0x01 + value | 1 + sizeof(v) |
Vec<T> | u32 count + count × element | 4 + Σ |
struct | field values back-to-back, no names | Σ |
Enum (external) | u32 tag_index + payload | 4 + payload |
Enum (adjacent) | struct{tag: String, content: struct{...}} | varies |
Self-delimiting for fixed-width primitives, Option, and Vec. Structs
rely on the visitor knowing the field count (passed via
deserialize_struct's n parameter) — there are no length prefixes or
field separators between struct fields.
Example encoding
struct Point { int32_t x; int32_t y; };
XPP_SERDE(Point, (x), (y))
Point{42, -7} encodes to 8 bytes:
2a 00 00 00 f9 ff ff ff
└─ x = 42 ─┘ └─ y = -7 ─┘
For comparison, the JSON encoding is {"x":42,"y":-7} — 15 bytes,
nearly 2x larger.
Schema Evolution
Because field names are not on the wire, schema changes require care:
| Change | Compatible? | Notes |
|---|---|---|
| Add field at the end | No (old data is shorter) | Reader expects the field, hits Eof. |
| Add field at the end + old reader | Yes | Old reader stops after existing fields; new field ignored. |
| Remove field | No | Reader expects it, data is misaligned. |
| Reorder fields | No | Binary is positional. |
| Change field type | No | Width/encoding mismatch. |
Add Option<T> field at end | Partial | Old data has no byte for it — reader hits Eof. Use a version prefix instead. |
For forward-compatible binary protocols, version the format explicitly:
prefix the payload with a u32 version and dispatch on it in the
Deserialize<T> specialization. Alternatively, use XPP_FIELD_SKIP on
the sender side to omit new fields — but both ends must agree on which
fields are skipped.
Cross-Backend Interop
The same Serialize<T> / Deserialize<T> specialization works for both
JSON and binary. You can serialize to JSON, deserialize from JSON, then
re-serialize to binary (or vice versa) with no code changes:
// JSON -> Person -> binary
auto jd = json::Deserializer::from_string(json_str).unwrap();
Person p = serde::deserialize<Person>(jd).unwrap();
bin::Serializer ser;
serde::serialize(p, ser);
Vec<uint8_t> bytes = ser.into_buffer();
This is useful for transcoding at protocol boundaries (e.g. receive JSON from an HTTP API, store as binary in a cache).
Error Handling
Binary-specific failures:
| Scenario | ErrorKind |
|---|---|
| Truncated input (ran out of bytes mid-field) | Eof |
Invalid UTF-8 in String | InvalidValue |
f64 is NaN/Inf on serialize | InvalidValue |
Option discriminator byte is neither 0x00 nor 0x01 | InvalidValue |
Enum tag_index out of range | InvalidValue |
Unlike JSON, there is no "unknown field" concept — the reader consumes
exactly the fields it expects, in order. Extra trailing bytes are
silently ignored (the Deserializer stops at the last field, not at end
of buffer).
Handle
Introduction
handle.h provides a single type alias:
template <class Allocator>
using OwnedHandle = Own<void, Allocator>;
libx's opaque handles are all typedef void* xFoo (via XDEF_HANDLE). Wrapping them with Own<void, Allocator> is correct but exposes void at every use site. OwnedHandle hides void behind a name that communicates intent: "I own a handle pointer."
Usage
struct EventLoopDestroy {
void deallocate(void* h, xpp::Layout) const noexcept {
xEventLoopDestroy(static_cast<xEventLoop>(h));
}
};
class EventLoop {
OwnedHandle<EventLoopDestroy> m_loop;
// Equivalent to: Own<void, EventLoopDestroy> m_loop;
};
The allocator only needs a deallocate(void*, Layout) method — allocate is never called because handles come from the C API (e.g. xEventLoopCreate), not from the allocator. It typically casts to the correct handle type and calls the corresponding xXxxDestroy function. EBO applies — if the allocator is stateless, sizeof(OwnedHandle<Allocator>) == sizeof(void*).
Shared
compiler.h — Portable Compiler Attributes
Introduction
compiler.h wraps non-portable compiler extensions (__builtin_*, __attribute__, __declspec) behind XPP_-prefixed macros that degrade gracefully on unsupported toolchains. Self-contained — no project dependencies.
Macros
Branch Prediction
| Macro | Description | Fallback |
|---|---|---|
XPP_LIKELY(x) | Branch expected true: __builtin_expect(!!(x), 1) | (x) |
XPP_UNLIKELY(x) | Branch expected false: __builtin_expect(!!(x), 0) | (x) |
Function Attributes
| Macro | Description | Fallback |
|---|---|---|
XPP_NORETURN | [[noreturn]] | Compiler-specific or empty |
XPP_FORCE_INLINE | inline __attribute__((always_inline)) | inline |
XPP_NOINLINE | __attribute__((noinline)) | Empty |
Control Flow
| Macro | Description | Fallback |
|---|---|---|
XPP_UNREACHABLE() | __builtin_unreachable() | std::abort() |
XPP_FALLTHROUGH | [[fallthrough]] (C++17) or __attribute__((fallthrough)) | ((void)0) |
Deprecation
| Macro | Description |
|---|---|
XPP_DEPRECATED(msg) | [[deprecated(msg)]] (C++14) or __attribute__((deprecated(msg))) |
Feature Detection
| Macro | Description |
|---|---|
XPP_DEBUG | 1 in debug (NDEBUG off), 0 in release. Override with -DXPP_DEBUG=. |
XPP_HAS_COROUTINES | 1 if C++20 coroutines are available. Checks __cplusplus >= 202002L AND __cpp_coroutines >= 201902L. Override with -DXPP_HAS_COROUTINES=0/1. |
XPP_FIBER | 1 when fiber support is enabled (libxpp CMake option). Enables xpp::fiber() and fiber-aware .await(). |
Usage
// Cold path: assertion failure
if (XPP_UNLIKELY(!ptr)) {
log_error_and_abort();
}
// Intentional switch fallthrough
case STATE_A:
init_a();
XPP_FALLTHROUGH;
case STATE_B:
process();
// Feature-gated coroutine support
#if XPP_HAS_COROUTINES
// C++20 coroutine code
#endif
Implementation Notes
XPP_DEBUG defaults to !defined(NDEBUG) but is overridable at the command line. This allows enabling debug assertions in release builds (-DXPP_DEBUG=1) or disabling them in debug builds (-DXPP_DEBUG=0) for benchmarking.
XPP_HAS_COROUTINES checks both __cpp_coroutines and __cpp_impl_coroutine — Apple Clang historically defined only the latter.
panic.h — Fatal Error Reporting
Introduction
panic.h provides macros for reporting unrecoverable contract violations. Three levels of assertion:
| Macro | When checked | Use case |
|---|---|---|
XPP_PANIC(fmt, ...) | Always | Unconditional termination |
XPP_ASSERT(cond, fmt, ...) | Always (release too) | Public API contract checks |
XPP_DEBUG_ASSERT(cond, fmt, ...) | Debug only (XPP_DEBUG=1) | Internal invariants on hot paths |
Panics are for bugs, not runtime conditions. For recoverable errors, use Result<T, E>.
API Reference
XPP_PANIC
XPP_PANIC("invariant X violated");
XPP_PANIC("idx %zu out of range (size=%zu)", idx, size);
Outputs panic at <file>:<line>: <message> and terminates the process. The format suffix — (U+2014 em dash) separates the standard prefix from the user message.
XPP_ASSERT
XPP_ASSERT(ptr != nullptr, "null pointer passed to %s", func_name);
XPP_ASSERT(idx < size, "idx=%zu size=%zu", idx, size);
Output: panic at foo.cpp:42: assertion failed: idx < size — idx=7 size=4
The condition is always evaluated, even in release builds. The stringified condition is automatically included in the panic message.
XPP_DEBUG_ASSERT
XPP_DEBUG_ASSERT(m_has_value, "internal: Option storage uninitialized");
XPP_DEBUG_ASSERT(idx < size, "idx=%zu size=%zu", idx, size);
Compiled to ((void)0) when XPP_DEBUG=0. Used for *Unchecked() APIs (e.g. unwrap_unchecked) and internal invariants.
XPP_DEBUG
Controlled by NDEBUG:
NDEBUGnot defined →XPP_DEBUG = 1(Debug build)NDEBUGdefined →XPP_DEBUG = 0(Release build)
Override with -DXPP_DEBUG=1 or -DXPP_DEBUG=0 to decouple from build type.
Usage Pattern
// Public API: always check — caller might misuse
T& Option<T>::unwrap() & {
XPP_ASSERT(m_has_value, "unwrap() on None Option");
return *reinterpret_cast<T*>(&m_storage);
}
// Internal hot path: debug check only — caller has verified
T& Option<T>::unwrap_unchecked() & noexcept {
XPP_DEBUG_ASSERT(m_has_value, "internal: Option must be Some");
return *reinterpret_cast<T*>(&m_storage);
}
Implementation Notes
do_panic is defined in panic.cpp (not header-only) to avoid pulling in the libx logging dependency transitively. A type-safe printf-style __attribute__((format(printf, 1, 2))) on GCC/Clang lets the compiler validate format strings at every macro use site.
XPP_ASSERT uses XPP_UNLIKELY to hint the branch predictor that the assertion rarely fires, keeping the hot path in the instruction cache.
event.h — RAII Event Loop Wrapper
Introduction
event.h provides C++ RAII wrappers for the libx event loop. Two classes with distinct responsibilities:
EventLoop— Owns thexEventLoophandle. Creates on construction, destroys when out of scope. Move-only.WaitScope— Binds the loop to the current thread (xEventLoopEnter/Leave). Non-copyable, non-movable — tied to its scope.
This separation mirrors the C API where xEventLoopCreate/Destroy and xEventLoopEnter/Leave are independent operations. EventLoop manages the resource; WaitScope manages the thread binding.
Design Philosophy
-
Handle ownership vs. thread binding are separate concerns. Creating a loop and binding it to a thread are two orthogonal operations. Collapsing them into a single RAII type would complicate sharing and lifetime management.
-
WaitScope is scope-tied, not a movable object.
xEventLoopEnter/Leaveform a stack-like pair. Moving the guard would leave the original scope without a correspondingLeave, violating the contract. -
The handle itself is thread-safe.
stop(),wake(), andxEventLoopPostcan be called from any thread.run(),xTimerStart, andxEventAddmust be called from the entered thread. -
EventLoop::current()panics outside WaitScope. This catches the common bug of callingPromise::await()without an active event loop binding. -
Fiber integration. With
XPP_FIBER,PromiseContext::park()can suspend a fiber viaxFiberYield()instead of blocking the thread withX_RUN_ONCE. The same WaitScope and EventLoop drive all fibers — no separate scheduler needed. See.await()docs.
API Reference
EventLoop
| Member | Description |
|---|---|
EventLoop() | Create an event loop. operator bool() checks success. |
~EventLoop() | Destroy the loop. Does NOT call xEventLoopLeave. |
EventLoop(EventLoop&&) | Move ctor. Source becomes falsy. |
EventLoop& operator=(EventLoop&&) | Move assignment. Old loop destroyed. |
void run(RunMode mode) | Run the loop (blocks until stopped or idle). |
void stop() | Stop a running loop (thread-safe). |
void wake() | Wake from epoll_wait/kevent (thread-safe). |
xEventLoop handle() | Access the underlying C handle for interop. |
operator bool() | True if the loop was created successfully. |
static xEventLoop current() | The loop bound to this thread. Panics outside WaitScope. |
RunMode
| Value | Behavior |
|---|---|
RunMode::Default | Block until stop() or no more active handles. |
RunMode::Once | Single iteration, block until at least one event. |
RunMode::NoWait | Single iteration, non-blocking poll. |
WaitScope
| Member | Description |
|---|---|
WaitScope(const EventLoop&) | Enter the loop. Binds it to the current thread. |
~WaitScope() | Leave the loop. Unbinds the thread. |
| Non-copyable, non-movable | The enter/leave pair must stay in one scope. |
Usage Examples
Basic event loop
#include <xpp/event.h>
#include <xpp/timer.h>
int main() {
xpp::EventLoop loop;
{
xpp::WaitScope scope(loop);
// One-shot: fire once after 100ms, then stop the loop
xpp::Timer(100, 0, [&]() { loop.stop(); });
loop.run(); // Blocks ~100ms, then timer fires → stop
}
// WaitScope leaves the loop here
}
Interop with Promise<T>
#include <xpp/event.h>
#include <xpp/promise.h>
#include <xpp/timer.h>
xpp::EventLoop loop;
{
xpp::WaitScope scope(loop);
auto r = xpp::PromiseResolver<int>::create();
xpp::Timer(50, 0, [&]() { r.resolve(42); });
int result = r.promise().await(); // EventLoop::current() succeeds
}
Manual wake from another thread
#include <thread>
xpp::EventLoop loop;
{
xpp::WaitScope scope(loop);
std::thread worker([&]() {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
loop.wake(); // Thread-safe: unblocks epoll_wait
// or: loop.stop();
});
loop.run();
worker.join();
}
Comparison
xpp::EventLoop + WaitScope | uv_loop_t + uv_run() | asio::io_context | |
|---|---|---|---|
| Ownership | RAII (move-only) | Manual alloc/free | RAII (copyable or moveable) |
| Thread binding | Explicit via WaitScope | Implicit on first call | Explicit via run() |
| Wake from another thread | loop.wake() | uv_async_send | post() |
| Size | sizeof(void*) (opaque handle) | ~1KB struct | Large (many members) |
| Embeddability | Zero deps beyond libx | libuv needed | Boost/standalone asio needed |
Implementation Notes
Storage
class EventLoop {
OwnedOpaquePointer<Destroy> m_loop;
};
EventLoop stores an OwnedOpaquePointer<Destroy> — essentially Own<void, Destroy> — where Destroy is a stateless deleter that calls xEventLoopDestroy. The handle itself is opaque; operator bool() delegates to Own::operator bool().
EventLoop::current()
static xEventLoop current() {
xEventLoop loop = xEventLoopCurrent();
XPP_ASSERT(loop != nullptr, "EventLoop::current() called outside WaitScope");
return loop;
}
On macOS/Linux, xEventLoopCurrent() reads a __thread / thread_local variable set by xEventLoopEnter. No global registry, no lookup — pure thread-local storage. The assert catches the case where no WaitScope is active on the calling thread.
Promise<T> — Composable Deferred Values
Introduction
Promise<T> provides a type-safe async programming system within the libx event loop. It combines the poll-based model from Rust's Future trait with the node-hierarchy and per-chain arena allocation from KJ (Cap'n Proto), plus stackful fiber support via xpp::fiber().
The core API (.then(), .await(), resolve(), all(), race()) is C++11. C++20 is required only for co_await / co_return.
Promise<T> supports three equivalent coding styles — pick whichever fits your compiler and preference:
C++11 + .await() (works everywhere):
xpp::Promise<int> compute() {
return fetch_value()
.then([](int x) { return x * 2; });
}
int result = compute().await(); // drives event loop if not in fiber
C++11 + fiber (non-blocking inside xpp::fiber()):
int result = xpp::fiber([]() {
int x = fetch_value().await(); // fiber suspends, event loop continues
return x * 2;
}).await();
C++20 co_await / co_return:
xpp::Promise<int> compute() {
int x = co_await fetch_value();
co_return x * 2;
}
int result = compute().await();
All three are backed by the same poll()-based state machine.
Design Philosophy
.await()First —.await()is the universal entry point. Outside a fiber it drivesxEventLoopRun()directly. Inside a fiber (viaxpp::fiber()) it suspends the fiber viaxFiberYield()— non-blocking, stackful concurrency withoutco_awaitsyntax.- One-Shot Polling —
poll()returnsOption<T>:Some(value)= ready,None= pending. No separatetake(). - Single-Threaded Executor — Like Tokio's
current_threadruntime. The event loop is both reactor (I/O) and scheduler (timers/callbacks). No background thread pool needed. - Auto-Flatten —
.then(fn)returningPromise<U>becomesPromise<U>, notPromise<Promise<U>>. - Lock-Free Cross-Thread Resolve —
PromiseResolverholdsArcWeak;resolve()silently drops if Promise is destroyed. - Void-Aware Templates —
Void+FixVoid<T>mapsvoid → Voidfor uniform generic code. - Nested
.await()Is Safe —WaitScopeowns the loop binding; nestedRuncalls don't unbind it. - Per-Chain Arena —
.then()chains bump-allocate nodes in a shared 256-byte arena, reducing malloc calls from O(N) to O(1) per chain. Overflow nodes fall back to heap transparently. - Coroutine-Native —
Promise<T>is both a poll-based node container and a C++20 coroutine return type.co_awaitdrives the samepoll()mechanism as.then(). - Adapter Pattern — External async operations (timers, thread pool, custom I/O) bridge into the poll world via
AdapterPromiseNode+PromiseResolver.
Architecture
graph TD
subgraph "User API"
PR["resolve(v)"]
CHAIN[".then(fn)"]
AWAIT[".await()"]
FIBER["xpp::fiber()"]
ASYNC["async<T>()"]
PRR["PromiseResolver<T>"]
CORO["co_await / co_return"]
end
subgraph "PromiseNode Hierarchy"
BASE["PromiseNode<T><br/>poll(waker) → Option<T>"]
IMM["ImmediatePromiseNode"]
TRANS["TransformPromiseNode"]
CHAINP["ChainPromiseNode"]
ADAPT["AdapterPromiseNode<T, Adapter>"]
MANUAL["ManualResolveNode<T>"]
COROP["CoroutinePromiseNode<T>"]
end
subgraph "Shared State"
RS["ResolveState<T><br/>Arc / ArcWeak"]
AW["AtomicPromiseWaker"]
ARENA["PromiseArena (256B)<br/>per-chain bump allocator"]
FIB["xFiber / xFiberYield<br/>stackful suspend"]
end
PR --> IMM
CHAIN --> TRANS
CHAIN --> CHAINP
ASYNC --> MANUAL
ASYNC --> PRR
PRR --> RS
ADAPT --> RS
CORO --> COROP
AWAIT --> BASE
FIBER --> FIB
FIB --> AWAIT
BASE --> AW
RS --> AW
TRANS -.->|arena-allocated| ARENA
CHAINP -.->|arena-allocated| ARENA
style AWAIT fill:#4a90d9,color:#fff
style FIBER fill:#50b86c,color:#fff
style RS fill:#e91e63,color:#fff
style ADAPT fill:#50b86c,color:#fff
style ARENA fill:#f5a623,color:#fff
style CORO fill:#9b59b6,color:#fff
style FIB fill:#4a90d9,color:#fff
Topics
.await()& Fiber —.await()semantics, fiber suspend, event loop integration- then() — chaining, auto-flatten, type transformations
- Deferred Resolution —
async(),PromiseResolver, cross-thread resolve - Timers & Timeouts —
after(ms), timeout pattern withrace - Combinators —
all(),race(), concurrent composition - Utilities —
try_next(), sequential fall-through - Custom Adapters —
adapt,work, Adapter contract,TimerAdapter,WorkAdapter - C++20 Coroutines —
co_await/co_returnwithPromise<T>(noTask<T>) - Internals —
PromiseNodehierarchy, waker system,ResolveState
API Reference
Promise<T>
| Member | Description |
|---|---|
auto then(Func fn) | Chain transform. If fn returns Promise<U>, auto-flattens to Promise<U> |
Promise<void> discard() | Drop value, return Promise<void> |
T await() | Wait for resolve. Fiber-aware: suspends inside xpp::fiber(), blocks otherwise |
operator co_await() | (C++20 only) Await in coroutine. Rvalue-qualified |
operator bool() | True if non-empty (holds a node) |
PromiseResolver<T>
| Member | Description |
|---|---|
void resolve(T v) | Fulfill with value. Thread-safe. Silently drops if Promise destroyed |
void resolve() | (void specialization) Fulfill with no value |
bool is_pending() | True if Promise alive and unresolved |
Free Functions
| Function | Description |
|---|---|
resolve(v) | Immediately-resolved promise. T deduced from argument |
yield() | Immediately-resolved Promise<void> |
fiber(fn) | Run fn in a stackful fiber (64KB stack). Returns Promise<decltype(fn())> |
after(ms) | Resolve after ms milliseconds. Returns Promise<void> |
lazy(fn) | Wrap sync function as lazy promise; runs on first poll. T deduced from return type |
work(fn) | Run func on thread pool. T deduced from return type |
adapt<T, Adapter>(args...) | Custom adapter-backed promise |
async<T>() | → pair<Promise<T>, PromiseResolver<T>> |
all(Promise<Ts>...) | Wait for all → tuple or void |
race(Promise<T>, Promise<T>...) | First resolved wins |
try_next(items, fn) | Try each item sequentially, return first ok |
.await() — Waiting for a Promise
.await() is the canonical way to extract a value from a Promise<T>. It's context-aware — the same call behaves differently depending on where you are.
Outside a fiber: Inside xpp::fiber():
park() park()
X_RUN_ONCE xFiberYield()
(thread blocks) (fiber suspends, event loop keeps running)
Two contexts, one API
1. Direct (non-fiber) — drives the event loop
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
int result = fetch_value() // Promise<int>
.then([](int x) { return x * 2; })
.await(); // runs X_RUN_ONCE until resolved
2. Inside a fiber — non-blocking suspend
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
xpp::fiber([]() {
auto a = http_get("/a").await(); // fiber suspends
auto b = http_get("/b").await(); // resumes when a is ready
return a + b;
}).then([](int total) {
printf("total = %d\n", total);
});
loop.run(); // one thread drives all fibers + I/O
This is the key insight: .await() means "wait for this Promise, letting the event loop continue in the meantime". Outside a fiber, you run the loop. Inside a fiber, you return control to the loop.
How it works
// Simplified — the real implementation is in PromiseContext::park()
T Promise<T>::await() {
PromiseContext cx; // auto-detects fiber context
while (true) {
Option<T> result = m_node->poll(cx);
if (result.is_some()) return result.unwrap();
cx.park(); // fiber: xFiberYield | non-fiber: X_RUN_ONCE
}
}
- Poll — ask the promise node if the value is ready.
- Park — if not ready, let the event loop make progress:
- Fiber: suspend via
xFiberYield(), the event loop runs on the main stack, and the waker switches back when the promise resolves. - Non-fiber: call
xEventLoopRun(X_RUN_ONCE)to process one batch of events (timers, I/O, done queue).
- Fiber: suspend via
- Repeat until
poll()returnsSome<T>.
Nested .await() is safe
A promise chain can call .await() on another promise — the WaitScope uses a thread-local pointer, so nested calls don't unbind the outer scope.
Promise<User> fetch_user() {
return fetch_id().then([](int id) {
return fetch_from_db(id).await(); // nested await — safe
});
}
User u = fetch_user().await();
.await() in tests
Almost every test follows this pattern:
TEST(MyTest, Example) {
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
my_test_coroutine().await(); // drive to completion
}
await() vs co_await
.await() | co_await | |
|---|---|---|
| Available in | C++11 + fiber | C++20 |
| Blocks thread | Yes (if not in fiber) | No (suspends coroutine) |
| Inside fiber | Suspends fiber (non-blocking) | N/A |
| Use case | main, tests, fibers, sync code | Inside another coroutine |
| Mechanism | poll() + park() | poll() + compiler-generated state machine |
They're the same mechanism at different levels. .await() is the universal entry point — it works everywhere. co_await is a C++20 sugar inside coroutine functions.
then() — Chaining & Auto-Flatten
then() is the primary chaining mechanism for Promise<T>. It transforms a resolved value into a new Promise, with automatic flattening.
Basic chaining — .await()
xpp::resolve(10)
.then([](int x) { return x * 2; }) // Promise<int>
.then([](int x) { return x + 1; }) // Promise<int>
.then([](int x) { // Promise<void>
printf("result: %d\n", x);
});
// final chain: Promise<void>
Each .then() adds one link to the chain. The chain only starts running when the root promise (here resolve(10)) is polled by the event loop.
Auto-Flatten
If a .then() callback returns a Promise<U>, the result is flattened to Promise<U> — not Promise<Promise<U>>. This is the same behavior as Rust's Future::and_then and JavaScript's Promise.then.
Promise<User> fetch_user(int id) { /* ... */ }
Promise<Order> fetch_order(User &u) { /* ... */ }
Promise<Order> order = resolve(42)
.then([](int id) { return fetch_user(id); }) // → Promise<User> (flattened)
.then([](User u) { return fetch_order(u); }); // → Promise<Order> (flattened)
Without auto-flatten, the result would be Promise<Promise<User>> — requiring .then().then() to unwrap. With it, you write a flat chain.
Type transformations
Promise<std::string> msg = resolve(10)
.then([](int x) { return x * 2; }) // Promise<int>
.then([](int x) { return std::to_string(x); }); // Promise<std::string>
Each .then(fn) turns Promise<T> into Promise<decltype(fn(T))>. The compiler tracks types through the entire chain.
Void handling
Promise<> p = resolve(10)
.then([](int x) { printf("%d\n", x); });
// p is Promise<void>
then() is non-mutating
Each .then() call returns a new Promise — the original is untouched. This means you can fork a chain into multiple consumers:
auto root = fetch_value();
auto doubled = root.then([](int x) { return x * 2; });
auto tripled = root.then([](int x) { return x * 3; });
Error handling
There's no built-in catch method. Errors are propagated through the chain as regular values using Result<T, E>:
Promise<Result<int, MyError>> compute = resolve(42)
.then([](int x) -> Result<int, MyError> {
if (x == 0) return err(MyError::DivideByZero);
return ok(100 / x);
})
.then([](Result<int, MyError> r) {
return r.is_ok() ? r.unwrap() * 2 : 0;
});
Arenas: allocation model
Each .then() chain shares a 256-byte bump allocator (arena). Promise nodes are allocated from this arena, not individually heap-allocated. This means a 10-link chain is a single malloc (the arena) rather than 10 separate allocations. Nodes that overflow the arena fall back to heap transparently.
Driving the chain
Chains are lazy — nothing runs until polled. Use .await() to drive the chain to completion on the current thread:
int result = resolve(10)
.then([](int x) { return x * 2; })
.await();
// result == 20
then() vs .await() vs co_await
These three are equivalent:
// C++11: then() + .await()
Promise<int> p = fetch_value()
.then([](int x) { return x * 2; });
// C++11 + fiber: .await()
int val = xpp::fiber([]() {
int x = fetch_value().await();
return x * 2;
}).await();
// C++20: co_await
Promise<int> p = async_compute(); // inside: int x = co_await fetch_value(); co_return x * 2;
All produce the same Promise<int> backed by the same poll() mechanism.
Deferred Resolution
When to Use
You have an async operation that completes later (timer, network, thread pool) and want to create a Promise that resolves when it finishes.
xpp::async<T>()
Returns std::pair<Promise<T>, PromiseResolver<T>>. The resolver is safe to call after the Promise is destroyed — it holds ArcWeak, so resolve() silently drops if the Promise is gone.
#include <xpp/promise.h>
#include <xpp/promise_adapter.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [p, r] = xpp::async<int>();
// Resolve from a timer callback
xpp::Timer t(100, 0, [&]() { r.resolve(42); });
int result = p.await(); // blocks ~100ms
// result == 42
Cross-Thread Resolve
resolve() is thread-safe — ArcWeak::upgrade() is a CAS loop. Safe to call from any thread.
#include <thread>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [p, r] = xpp::async<std::string>();
std::thread worker([&]() {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
r.resolve(std::string("from another thread"));
});
std::string result = p.await();
// result == "from another thread"
worker.join();
Safe After Promise Destruction
If the Promise is destroyed before resolve() is called (e.g., a losing branch in race()), resolve() silently drops — no UAF.
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
xpp::PromiseResolver<int> r;
{
auto [p, r2] = xpp::async<int>();
r = std::move(r2);
// p is destroyed when the scope ends
}
// Promise is gone. resolve() safely drops.
r.resolve(42);
// No crash.
Double Resolve
Only the first resolve() takes effect. Subsequent calls are silently dropped via compare_exchange_strong on the resolved flag.
auto [p, r] = xpp::async<int>();
r.resolve(42);
r.resolve(99); // silently dropped
EXPECT_EQ(p.await(), 42);
Nested wait()
wait() inside a .then() callback is safe — WaitScope owns the loop binding, nested xEventLoopRun doesn't unbind it.
auto [outer_p, outer_r] = xpp::async<int>();
auto [inner_p, inner_r] = xpp::async<int>();
// Schedule: outer at 60ms, inner at 30ms
// ...
int result = outer_p
.then([&](int outer_val) {
int inner_val = inner_p.await(); // nested Run
return outer_val + inner_val;
})
.await();
Best Practices
PromiseResolvercan safely outlive the Promise.ArcWeak—resolve()silently drops. No UAF.is_pending()is not atomic. Check only from the owner thread.- Don't
wait()on an empty promise. Checkoperator bool()first. - Nested
wait()is safe but beware deadlocks. If the inner promise is never resolved,wait()spins indefinitely.
Timers & Timeouts
When to Use
You need a delay, a timeout, or want to race a Promise against a timer.
after(ms)
Resolves after ms milliseconds. Available only on Promise<void>. Chain with .then() to run code after the delay.
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
xpp::after(100)
.then([]() { printf("100ms elapsed\n"); })
.await();
Internally uses AdapterPromiseNode<void, TimerAdapter>. The TimerAdapter owns an xTimer handle:
- Timer fires → callback calls
m_resolver.resolve()→ setsresolved=true, wakes poller - Promise destroyed early →
~TimerAdapter()callsxTimerStop(if not yet fired, checked viam_firedatomic flag) - Loop destroyed →
on_cancelcallback nullsm_handle, setsm_fired=true
The promise must be destroyed on the same WaitScope thread.
Timeout Pattern with race
Combine after() with race() to implement timeouts:
#include <xpp/promise_combinators.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
// Fetch takes 100ms, timeout is 10ms → timeout wins
int result = xpp::race(
xpp::after(100).then([] { return 200; }), // "fetch"
xpp::after(10).then([] { return -1; }) // timeout
).await();
// result == -1 (timeout)
When race resolves, the losing branch is destroyed. TimerAdapter's destructor calls xTimerStop — the 100ms timer is cancelled, no callback fires after destruction.
Sequential Delays
xpp::after(10)
.then([]() { return xpp::after(20); }) // auto-flattened
.then([]() { printf("30ms total\n"); })
.await();
Void Promise Chains
int counter = 0;
xpp::yield()
.then([&]() { counter++; })
.then([&]() { counter++; })
.await();
// counter == 2
lazy() and yield()
// defer: wrap a sync function as a promise
int result = xpp::lazy([] { return 42; }).await();
// result == 42
// yield: immediately-resolved Promise<void>, chain entry point
int val = xpp::yield().then([] { return 1; }).await();
// val == 1
Combinators: all and race
When to Use
You need to wait for multiple Promises concurrently (all) or take the first result (race).
xpp::all(Promise<Ts>...)
Waits for all input promises, collecting results into a std::tuple.
- Heterogeneous: each promise may have a different type
- All-void: returns
Promise<void>(notPromise<tuple<Void, Void, ...>>) - Zero arguments: rejected by
static_assert
#include <xpp/promise_combinators.h>
// Heterogeneous — returns tuple
auto [status, body] = xpp::all(
fetch_status(url), // Promise<int>
fetch_body(url) // Promise<std::string>
).await();
// All-void — returns void
xpp::all(
prefetch(0),
prefetch(1),
prefetch(2)
).then([] { start_playback(); }).await();
// With void + value — Void in tuple, ignored
auto [_, val] = xpp::all(
yield(),
resolve(42)
).await();
// val == 42
xpp::race(Promise<T> first, Promise<Rest>...)
Resolves with the first ready promise. All losing branches are destroyed.
- Homogeneous: all promises must have the same type
T - Void support:
race(Promise<void>...)returnsPromise<void>
// Timeout pattern
auto result = xpp::race(
fetch_async(url), // Promise<int>
xpp::after(5000).then([] { return -1; }) // timeout
).await();
// N CDNs, take fastest
auto fastest = xpp::race(fetch(cdn1), fetch(cdn2), fetch(cdn3)).await();
// Void race
xpp::race(after(10), after(50)).await();
// resolves at ~10ms
How Waker Sharing Works
Both all and race pass the same PromiseContext to all children. When any child fires the waker (via cx.waker().wake()), await() re-polls the parent node.
Re-polling is safe because the parent tracks which children are done:
AllTuplePromiseNode: checksOption::is_some()per childRacePromiseNode: returns on firstSome, remaining children destroyed
wait() uses X_RUN_ONCE
Promise::wait() runs the event loop with X_RUN_ONCE (one iteration per call) rather than X_RUN_DEFAULT. This is critical for race: with two timers, the faster timer sets woken = true, but the slower timer keeps the loop alive. X_RUN_ONCE returns after each event, letting wait() re-check woken immediately.
Destruction Safety for race
When race resolves, N-1 losing children are destroyed:
| Node | Destructor behavior |
|---|---|
TimerAdapter | Calls xTimerStop if not yet fired |
AdapterPromiseNode | Destroyed — PromiseResolver::resolve() safely drops via ArcWeak |
ImmediatePromiseNode | No cleanup needed |
TransformPromiseNode | Destroys dependency chain |
With ArcWeak-based PromiseResolver, destroying an AdapterPromiseNode is safe — resolve() finds upgrade() == None and silently drops. No UAF.
Comparison with Other Languages
| Feature | xpp | JS | Rust | folly |
|---|---|---|---|---|
all | all(p1, p2) → tuple | Promise.all → array | try_join → tuple | collect → vector |
race | race(p1, p2) → first | Promise.race → first | select → first | any → first |
Heterogeneous all | Yes (tuple) | No | Yes (tuple) | No |
| Loser cleanup | Destructor | GC | Drop | Destructor |
| Executor needed | No | Yes | Yes | Yes |
Utilities: try_next
When to Use
You need to try multiple items with an async function, returning the first success — like falling through a list of DNS addresses until one connects.
xpp::try_next(items, fn)
Calls fn(item) on each item in items sequentially. Returns the first ok result, or the last err if all fail.
- C++11-compatible: uses
struct + std::move(*this)chaining, no coroutines or concepts - Zero heap allocation in the combinator itself (items and fn stored by value)
- Duck-typed:
fn(item)must returnPromise<Result<T, E>>whereResulthas.is_ok() - Factory function:
try_next(items, fn)returns a callable; invoke with()to start
#include <xpp/promise_utils.h>
// Try each resolved DNS address until one connects
std::vector<SocketAddr> addrs = co_await resolve_host("example.com");
auto stream = xpp::try_next(std::move(addrs), [&](const SocketAddr &a) {
return TcpStream::connect_with_conf(a.ip().c_str(), a.port(), conf.get());
})().await();
// Returns first ok, or last error (ConnectionRefused from final address)
Immediate results
When fn returns immediately-resolved promises, try_next evaluates without touching the event loop:
std::vector<int> items = {10, 20, 30};
auto result = xpp::try_next(std::move(items), [](int x) -> Promise<Result<int, int>> {
if (x == 20) return xpp::resolve(Result<int, int>(ok, x * 10));
return xpp::resolve(Result<int, int>(err, -x));
})().await();
// result == 200 (20 × 10), only tried 10 (failed) and 20 (ok)
Fall-through chain
When all items fail, returns the last error:
std::vector<int> items = {1, 2, 3};
auto err = xpp::try_next(std::move(items), [](int x) -> Promise<Result<int, int>> {
return xpp::resolve(Result<int, int>(err, x));
})().await();
// err == 3, all three were tried
Deferred (async) results
Works with async operations scheduled on the event loop:
std::vector<int> items = {10, 20, 30};
auto ar1 = xpp::async<Result<int, int>>();
auto ar2 = xpp::async<Result<int, int>>();
// r1 resolves with error at 10ms, r2 resolves with success at 20ms
schedule_resolve(ar1.second, Result<int, int>(err, -1), 10);
schedule_resolve(ar2.second, Result<int, int>(ok, 99), 20);
auto result = xpp::try_next(std::move(items), [&, p1 = std::move(ar1.first),
p2 = std::move(ar2.first)]
(int) mutable -> Promise<Result<int, int>> {
static int call_count = 0;
call_count++;
if (call_count == 1) return std::move(p1);
return std::move(p2);
})().await();
// result == 99 (second item succeeded after the first failed)
How It Works
TryNext<Items, Func> is a callable struct. Calling operator() pushes the first item through fn(), then chains a .then() callback:
// Simplified:
struct TryNext {
Items items;
size_t idx;
Func fn;
P operator()() {
return fn(items[idx++]).then(Then{std::move(*this)});
}
struct Then {
TryNext next; // ownership transferred via move
template <class R>
P operator()(R &&r) {
if (r.is_ok()) return xpp::resolve(std::forward<R>(r));
if (next.idx >= next.items.size())
return xpp::resolve(std::forward<R>(r)); // last error
return next(); // try next item
}
};
};
Key design points:
std::move(*this)ownership transfer: theTryNextstruct (withitemsandfnby value) moves through each.then()node in the Promise chain — noshared_ptrrefcount overhead- Template
operator()inThen: acceptsResult<T, E>without spelling out the types, enabling duck-typing of anyResulttype with.is_ok() - Tail-recursive via Promise chain:
return next()creates a new.then()link, rather than growing the call stack
Performance
| Allocation | Count |
|---|---|
| TryNext struct (items + fn) | 0 (stack/inline) |
| PromiseNode chain | 1 heap (head) + N arena bumps → 1 bulk free |
The combinator itself is zero-allocation. The Promise chain uses xpp's per-chain arena allocator, so only the head node hits the heap — subsequent .then() nodes are bump-allocated in the arena.
Why Not try_each?
The original name was try_each, but that misleadingly implies all items are always tried. The actual semantics are "try one, fail → try the next, succeed → stop". try_next captures this accurately: it parallels try_next() / next() iteration patterns in Rust's Iterator trait.
Custom Adapters
When to Use
You have an async operation (HTTP fetch, DNS lookup, thread pool work) and want to bridge it into the Promise system with automatic cancellation when the Promise is destroyed.
Adapter Contract
An Adapter is a class with:
- Constructor: receives
PromiseResolver<T>&&+ user args. Starts the async operation. - Destructor: cancels the async operation (if still in-flight).
- Async callback: calls
resolver.resolve(value)when done. Safe to call from any thread.
class MyAdapter {
PromiseResolver<int> m_resolver;
public:
MyAdapter(PromiseResolver<int>&& r, const char* url)
: m_resolver(std::move(r)) {
// Start async fetch...
// When done: m_resolver.resolve(response_code);
}
~MyAdapter() {
// Cancel the fetch if still in-flight
}
};
adapt<T, Adapter>(args...)
#include <xpp/promise_adapter.h>
auto p = xpp::adapt<int, MyAdapter>(url);
// MyAdapter is constructed with (PromiseResolver<int>&&, url)
// When the fetch completes, MyAdapter calls resolver.resolve(code)
int code = p.await();
How It Works
AdapterPromiseNode<T, Adapter>
├─ Arc<ResolveState<T>> m_state ← strong ref (keeps state alive)
├─ Adapter m_adapter ← owns the async operation
│ └─ PromiseResolver<T> ← weak ref (ArcWeak to state)
│
├─ poll(waker): ← generic, same for all adapters
│ if resolved → return value
│ else register waker → re-check → return None
│
└─ ~dtor: ~Adapter() → ~Arc() ← cancel op, then drop strong ref
ResolveState — Shared via Arc/ArcWeak
struct ResolveState<T> {
Option<T> value;
AtomicPromiseWaker waker;
std::atomic<bool> resolved{false};
};
- AdapterPromiseNode holds
Arc<ResolveState<T>>(strong) — keeps state alive. - PromiseResolver holds
ArcWeak<ResolveState<T>>(weak) —resolve()callsupgrade(). - When node is destroyed → strong count → 0 →
upgrade()returnsNone→resolve()silently drops.
Lifecycle Safety
Promise destroyed (e.g., race loser):
1. ~AdapterPromiseNode()
2. ~Adapter() → cancel async operation (e.g., xTimerStop)
3. ~Arc<State>() → strong count = 0
4. Async callback fires later → resolver.resolve(v)
5. ArcWeak::upgrade() → None (strong = 0) → silently drop
6. No UAF.
TimerAdapter — Built-in Adapter
TimerAdapter replaces the old TimerPromiseNode. It's a thin adapter (~25 lines) that owns an xTimer handle:
class TimerAdapter {
xTimer m_handle;
std::atomic<bool> m_fired{false};
PromiseResolver<void> m_resolver;
public:
TimerAdapter(PromiseResolver<void>&& r, uint64_t ms)
: m_resolver(std::move(r)) {
m_handle = xTimerStart(
[](void* a) {
auto* self = static_cast<TimerAdapter*>(a);
self->m_fired.store(true, release);
self->m_resolver.resolve();
},
this,
[](void* a) { // on_cancel (loop destroy)
auto* self = static_cast<TimerAdapter*>(a);
self->m_handle = nullptr;
self->m_fired.store(true, release);
},
ms, 0);
}
~TimerAdapter() {
if (!m_fired.load(acquire) && m_handle)
xTimerStop(m_handle);
}
};
Used internally by after(ms):
Promise<void> after(uint64_t ms) {
return adapt<void, TimerAdapter>(ms);
}
async<T>() — Manual Resolve
async uses ManualResolveNode<T> (same poll_state logic, no Adapter):
auto [p, r] = xpp::async<int>();
// p is backed by ManualResolveNode<int>
// r is PromiseResolver<int> (ArcWeak to shared state)
r.resolve(42);
p.await(); // 42
Writing a Custom Cross-Thread Adapter
The built-in WorkAdapter covers the common case. For custom adapters that
need more control (e.g., specific task group, progress reporting):
class MyFetchAdapter {
struct Ctx { PromiseResolver<Response> resolver; std::string url; };
Ctx* m_ctx;
xWork m_work;
public:
MyFetchAdapter(PromiseResolver<Response>&& r, const std::string& url)
: m_ctx(new Ctx{std::move(r), url}) {
m_work = xWorkSubmit(
nullptr,
[](void* a) -> void* {
auto* ctx = static_cast<Ctx*>(a);
Response resp = do_fetch(ctx->url);
ctx->resolver.resolve(std::move(resp)); // cross-thread, safe
return nullptr;
},
[](void* a, void*) { delete static_cast<Ctx*>(a); },
[](void* a, void*) { delete static_cast<Ctx*>(a); },
m_ctx);
}
~MyFetchAdapter() { if (m_work) xWorkCancel(m_work); }
};
auto p = xpp::adapt<Response, MyFetchAdapter>(url);
C++20 Coroutines
libxpp supports three ways to express async flows — pick the one that fits your compiler:
| Style | Compiler | Blocking? |
|---|---|---|
.then() chains | C++11 | No (callback-driven) |
.await() + fiber | C++11 + XPP_FIBER | No (stackful suspend) |
co_await / co_return | C++20 | No (compiler-generated state machine) |
When to Use co_await
You have multi-step async flows and want linear code. .await() + fiber already gives you this on C++11 — co_await is the C++20 sugar on top.
.await() + fiber (C++11)
int result = xpp::fiber([]() {
int x = xpp::resolve(1).await();
x = x + 1;
x = xpp::work([x] { return x * 2; }).await();
return x - 3;
}).await();
co_await / co_return (C++20)
## Requirements
C++20 compiler with coroutine support. Guarded by `XPP_HAS_COROUTINES` (defined in `<xpp/compiler.h>`). C++17 code is unaffected.
## Promise\<T\> as Coroutine Return Type
Any function returning `Promise<T>` can be a coroutine. Use `co_return` to produce the result:
```cpp
Promise<int> fetch_value() {
co_return 42;
}
Promise<void> do_something() {
co_return; // void coroutine
}
The coroutine is lazy — it doesn't start until wait() (or .then()) drives it via poll().
co_await Promise<U>
co_await works on any Promise<U> (rvalue). The coroutine suspends until the promise resolves, then the co_await expression yields the value:
Promise<int> fetch_and_parse() {
auto data = co_await Promise<std::string>::work(fetch_url);
auto result = co_await work([&] { return parse(data); });
co_return result;
}
You can co_await any Promise source:
Promise::resolve(v)— immediateafter(ms)— timerwork(fn)— thread poolasync<T>()— deferred (pass resolver to another thread)all(...)/race(...)— combinators- Another coroutine's return value
co_await void
co_await Promise<void> suspends and resumes with no value:
Promise<int> delayed_compute() {
co_await after(100); // wait 100ms
co_return 42;
}
Nested Coroutines
Coroutines can co_await other coroutines:
Promise<int> inner() {
co_await after(10);
co_return 100;
}
Promise<int> outer() {
int x = co_await inner();
co_return x + 1;
}
// outer().await() == 101
co_await Combinators
Promise<int> fetch_both() {
auto [status, body] = co_await xpp::all(
work(fetch_status),
Promise<std::string>::work(fetch_body)
);
co_return status + static_cast<int>(body.size());
}
Promise<int> fetch_with_timeout() {
int result = co_await xpp::race(
work(fetch),
after(5000).then([] { return -1; })
);
co_return result;
}
Coroutine + then Chain
Coroutines produce regular Promise<T>, so they compose with .then():
Promise<int> compute() { co_return 10; }
int result = compute().then([](int x) { return x * 3; }).await();
// result == 30
Early Destruction
If a coroutine's Promise<T> is destroyed before completion (e.g., a losing branch in race()), the coroutine frame is safely destroyed. The CoroutinePromiseNode destructor calls handle.destroy(), and any awaited promise's node is released.
{
auto p = slow_coro(); // coroutine that takes 10s
// p destroyed here — coroutine frame destroyed, no crash
}
How It Works
┌──────────────────────────────────────────────────────────────┐
│ CoroutinePromiseNode<T> : public PromiseNode<T> │
│ │
│ poll(waker): │
│ while (true): │
│ if done → return Some(result) │
│ if has await_state: │
│ poll(awaited_promise, waker) │
│ not ready → return None │
│ ready → clear await_state, resume coroutine │
│ else: │
│ resume coroutine (start or continue) │
│ (coroutine may co_return or co_await again) │
│ │
│ coroutine frame: │
│ co_await promise │
│ → PromiseAwaiter::await_suspend(handle) │
│ → extract node, store in CoroutinePromiseNode │
│ → suspend (return to poll) │
│ → poll() polls the node on next call │
│ → when ready, resume coroutine, await_resume() → value │
└──────────────────────────────────────────────────────────────┘
Key: while-loop in poll()
After handle.resume(), the coroutine may have co_awaited an already-resolved promise (e.g., Promise::resolve). The while loop immediately polls it and resumes — no None returned, no busy-loop in wait().
Type-erased await
A Promise<int> coroutine can co_await Promise<string>. The awaited node is stored via type-erased AwaitState:
AwaitStateImpl<U>— pollsPromiseNode<U>, stores result inOption<U>*VoidAwaitState— pollsPromiseNode<void>, setsbool*(becausePromiseNode<void>≠PromiseNode<Void>)
std::coroutine_traits
namespace std {
template <class T>
struct coroutine_traits<xpp::Promise<T>> {
using promise_type = xpp::_::CoroutinePromise<T>;
};
}
No Task<T> wrapper — Promise<T> IS the coroutine return type.
Internals
PromiseNode Hierarchy
All async computations implement PromiseNode<T> — a virtual interface with one method:
template <class T> class PromiseNode {
using ValueType = typename FixVoid<T>::Type;
virtual Option<ValueType> poll(const PromiseContext &cx) = 0;
};
Some(value)= ready, value extracted in the same callNone= pending, waker stored for later notification- One-shot: once
Someis returned,poll()must never be called again
Node Types
| Node Type | Purpose | poll() Behavior |
|---|---|---|
ImmediatePromiseNode<T> | Promise::resolve(v) | Returns Some(v) — ignores waker |
TransformPromiseNode<U, T, F> | .then(fn) | Polls dependency; if Some, applies fn |
ChainPromiseNode<T> | Auto-flatten Promise<Promise<T>> | Polls outer; when ready, switches to inner |
AdapterPromiseNode<T, Adapter> | Generic adapter pattern | Polls ResolveState: check resolved → register waker → double-check |
ManualResolveNode<T> | async<T>() factory | Same poll logic, no Adapter |
YieldPromiseNode | yield() | Returns Some(Void{}) |
AllTuplePromiseNode<Ts...> | all() combinator | Polls all children, collects tuple when all done |
AllVoidPromiseNode<N> | all() all-void | Countdown, returns Some(Void{}) when all done |
RacePromiseNode<T, N> | race() combinator | Returns first Some, destroys losers |
TransformPromiseNode
Four partial specializations handle the void-unit-type mapping: T→U, void→U, T→void, void→void. Uses _voidwrap::call / _voidwrap::call1 SFINAE helpers.
ChainPromiseNode
Uses m_inner != nullptr as a state machine: nullptr = polling outer, non-null = polling inner. No enum, no extra branch.
poll_state() — Shared Poll Logic
AdapterPromiseNode and ManualResolveNode share the same poll implementation via poll_state():
template <class T>
Option<T> poll_state(ResolveState<T> &s, const PromiseContext &cx) {
if (s.resolved.load(std::memory_order_acquire))
return std::move(s.value); // Fast path
s.waker.register_by_ref(cx.waker()); // May race with resolve
if (s.resolved.load(std::memory_order_acquire)) {
s.waker.wake(); // Self-wake
return std::move(s.value);
}
return none;
}
Void specialization returns Some(Void{}) when resolved (void resolve() doesn't set value).
ResolveState + PromiseResolver
struct ResolveState<T> {
Option<T> value;
AtomicPromiseWaker waker;
std::atomic<bool> resolved{false};
};
PromiseResolver::resolve() — thread-safe, safe after node destruction:
void resolve(ValueType &&value) {
auto s = m_weak.upgrade(); // ArcWeak → Option<Arc<State>>
if (s.is_some()) { // Node still alive?
auto &state = *s.unwrap();
if (state.resolved.compare_exchange_strong(false, true, acq_rel)) {
state.value = Option<T>(std::move(value));
state.waker.wake();
}
}
// else: node destroyed — silently drop
}
AtomicPromiseWaker — Lock-Free 2-Bit State Machine
Coordinates register_by_ref() (poll side) and wake() (resolve side) without a mutex:
| State | Bits | Meaning |
|---|---|---|
| WAITING | 00 | Idle |
| REGISTERING | 01 | poll side storing waker |
| WAKING | 10 | resolve side waking |
| RACE | 11 | Both collided — registerer self-wakes |
register_by_ref(new_waker):
CAS(00 → 01)
├─ success → store waker → exchange(01 → 00)
│ ├─ prev == 00 → done
│ └─ prev == 11 → self-wake
└─ failure →
├─ prev == 10 → wake(new_waker)
└─ prev == 11 → spin/CAS again
wake():
fetch_or(10)
├─ prev == 00 → exclusive → wake stored waker → store(00)
└─ prev == 01 → race → set WAKING bit
└─ registerer's exchange sees 11 → self-wakes
Three atomic operations total. No spin loops, no mutex. Memory ordering: store(release) on resolve, load(acquire) on poll, fetch_or(acq_rel) on wake.
PromiseWaker — Same-Thread vs Cross-Thread
PromiseWaker wraps an Arc<_::WakeState> — the inner state {loop, woken, fiber} allocated on the heap and shared via atomic refcount. All copies share the same state.
void wake() const {
if (m_state->loop == xEventLoopCurrent()) {
if (m_state->fiber) {
xFiberSwitch(m_state->fiber); // Fiber: direct switch, no flag
} else {
m_state->woken = true; // Non-fiber: set flag
}
} else {
xEventLoopPost(m_state->loop, // Cross-thread — MPSC enqueue
&on_wake, &(*m_state));
}
}
sizeof(PromiseWaker) = sizeof(Arc<_::WakeState>) = 8B. Same-thread fiber: direct xFiberSwitch. Same-thread non-fiber: flag set. Cross-thread: xEventLoopPost (lock-free MPSC).
PromiseContext is the non-cloneable poll handle that owns a PromiseWaker. It provides park() (fiber: single xFiberYield(); non-fiber: xEventLoopRun(X_RUN_ONCE) loop on woken flag) and waker() (returns const PromiseWaker& for resolvers to clone and store).
Nested wait() Correctness
xEventLoopRun does not call xEventLoopLeave on return. Enter/Leave is scoped to WaitScope:
class WaitScope {
public:
explicit WaitScope(const EventLoop &loop) : m_loop(loop.handle()) {
if (m_loop) xEventLoopEnter(m_loop);
}
~WaitScope() {
if (m_loop) xEventLoopLeave();
}
};
Call stack for nested wait():
wait() (outer)
poll() → None
xEventLoopRun() → timer fires → resolve → then() callback
.then(fn) → fn calls inner_promise.await()
wait() (inner)
poll() → Some(result) → return
fn returns result
woken==true → poll → Some → return
Both xEventLoopRun calls see the same thread-local loop handle. Neither inner Run exit unbinds it.
Integration Status
| Consumer | Node Type | Reason |
|---|---|---|
async<T>() | ManualResolveNode<T> | Deferred resolve (ArcWeak, safe after destruction) |
Promise::resolve(v) | ImmediatePromiseNode | Immediate completion |
Promise<void>::after(ms) | AdapterPromiseNode<void, TimerAdapter> | Timer-based delay |
Promise<T>::work(fn) | AdapterPromiseNode<T, WorkAdapter<T, F>> | Thread-pool work |
Promise<T>::adapt<Adapter>(args) | AdapterPromiseNode<T, Adapter> | Custom adapter |
.then(fn) | TransformPromiseNode / ChainPromiseNode | Transform / auto-flatten |
yield() | YieldPromiseNode | Chain entry point |
all(...) | AllTuplePromiseNode / AllVoidPromiseNode | Concurrent — wait for all |
race(...) | RacePromiseNode | Concurrent — first wins |
timer.h — Callback-Style Timer
Introduction
timer.h provides xpp::Timer, a move-only RAII wrapper around
xTimerStart / xTimerStop. It supports both one-shot and
repeating timers via a callback API, with stop() / start() for
pause/resume.
Timer complements Promise<void>::after(ms):
Promise<void>::after(ms) | xpp::Timer | |
|---|---|---|
| Style | Promise-based (poll/wait) | Callback-based |
| Modes | One-shot only | One-shot + repeating |
| Composition | .then(), .await() | None (just fires callback) |
| Use case | Delayed computation in a Promise chain | Periodic tasks, heartbeats, simple delayed callbacks |
API Reference
Construction
| Expression | Behavior |
|---|---|
Timer(ms, cb) | Repeating: fires every ms (timeout = repeat = ms) |
Timer(timeout, repeat, cb) | Explicit: first fire after timeout, then every repeat. repeat == 0 = one-shot |
cb is a callable with signature void(). It is stored by value
(decay-copy) inside a heap-allocated State.
Methods
| Method | Returns | Description |
|---|---|---|
stop() | void | Cancel the timer. Idempotent. |
start() | bool | Resume after stop(). false if already active or no live loop. |
is_active() | bool | True if timer is currently scheduled. |
operator bool() | bool | Equivalent to is_active(). |
handle() | xTimer | Underlying handle for C interop, or nullptr. |
Lifetime
- Move-only: copy is deleted. Move transfers ownership of the
internal
State. - RAII: destructor calls
stop()if the timer is active. - WaitScope contract: must be constructed within a
WaitScope. The callback runs on theWaitScopethread.
Usage Examples
Repeating timer
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
int ticks = 0;
xpp::Timer t(100, [&]() {
if (++ticks >= 5) loop.stop();
});
loop.run();
// 5 ticks, ~500ms
One-shot delayed callback
xpp::Timer t(1000, 0, [&]() {
printf("1 second elapsed\n");
});
Asymmetric repeating (fast first fire, slow subsequent)
// First fire at 10ms, then every 5s
xpp::Timer t(10, 5000, [&]() { poll_device(); });
Pause and resume
xpp::Timer t(100, [&]() { heartbeat(); });
// ... later, pause ...
t.stop();
// ... even later, resume ...
t.start(); // next fire is 100ms from now (not from when we paused)
Self-stop from callback
int n = 0;
xpp::Timer *t_ptr = nullptr;
xpp::Timer t(100, [&]() {
if (++n >= 3) t_ptr->stop();
});
t_ptr = &t;
Calling stop() from inside the callback is safe — libx re-arms
repeating timers before invoking the callback, so xTimerStop finds
a valid timer in the heap and removes it.
Notes
Callback exceptions
If the callback throws, the exception propagates through
xEventLoopRun to the caller of loop.run(). Throwing callbacks are
the user's responsibility.
No deadline preservation on resume
stop() discards the original deadline. start() schedules a fresh
timer with the original timeout_ms / repeat_ms. The next fire is
timeout_ms away, regardless of when stop() was called.
This matches libuv's uv_timer_stop / uv_timer_start semantics.
Loop-destroy cleanup
When the host event loop is destroyed with the timer still pending,
libx invokes the on_cancel hook (added in the x-timer-on-cancel
change). The hook nulls the stored handle, so ~Timer skips
xTimerStop. The user callback is NOT invoked on this path.
Comparison with Promise<void>::after(ms)
// Promise-based (use for Promise composition):
Promise<void>::after(100).then([]() {
return compute_result();
}).await();
// Callback-based (use for periodic tasks or simple callbacks):
xpp::Timer t(100, []() {
heartbeat();
});
Use after(ms) when you need .then() / .await() composition. Use
Timer when you need a periodic callback or a simple one-shot callback
without Promise overhead.
Filesystem
Introduction
xpp::fs::File provides Promise-based async file I/O that composes with .then(), co_await, .await(), and fibers. It wraps libx's xfs module, which offloads filesystem operations to a thread pool — callbacks never block the event loop.
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
// Direct .await() — blocks until done, drives event loop
auto file = xpp::fs::File::open("data.txt").await();
auto content = file.read_to_string().await();
// ~File() closes synchronously
// With fiber — non-blocking suspend
xpp::fiber([]() {
auto file = xpp::fs::File::open("data.txt").await();
auto data = file.read_all().await();
xpp::fs::write("out.txt", data.data(), data.size()).await();
}).await();
// With coroutines (C++20)
xpp::Promise<void> process() {
auto file = co_await xpp::fs::File::open("data.txt");
auto data = co_await file.read_all();
co_await xpp::fs::write("out.txt", data.data(), data.size());
}
Design Philosophy
-
pread/pwrite, no seek — All read/write take explicit
off_t offset. Thread-safe (no shared file position), simpler API (one less concept), matches libx'sxFsReq.offset. -
RAII with sync close —
~File()callsclose(fd)synchronously if still open. Blocking but fast (close()is near-instant). Callclose()explicitly for async close. -
Adapter pattern — Each operation (open, read, write, close, stat, mkdir, rmdir, unlink, rename) has a typed
FsAdapterthat bridgesxFsReqcallbacks toPromiseResolver<T>. Same lifecycle asTimerAdapterandWorkAdapter. -
Blocking I/O offloaded —
read_all/write_all/sync_all/statusexpp::work()to offload to the thread pool. Never blocks the event loop thread. -
Buffer overloads — C-style
void* + size_t(base, matches POSIX, works with all buffer types) andSpan<uint8_t>(type-safe). No buffer ownership — caller manages lifetime. -
Negative = error —
read/writereturnPromise<ssize_t>:>= 0= bytes transferred,< 0=-errno. Matches POSIX convention.
Architecture
graph TD
subgraph "User API"
OPEN["File::open / create"]
READ["file.read / write"]
CLOSE["file.close"]
ALL["file.read_all / write_all"]
FREE["stat / exists / create_dir / remove_dir / rename"]
end
subgraph "FsAdapter (via adapt())"
ADAPT["AdapterPromiseNode<T, FsXxxAdapter>"]
OA["FsOpenAdapter"]
RA["FsReadAdapter"]
WA["FsWriteAdapter"]
CA["FsCloseAdapter"]
SA["FsStatAdapter"]
MA["FsMkdirAdapter"]
UA["FsUnlinkAdapter"]
RNA["FsRenameAdapter"]
RDA["FsRmdirAdapter"]
end
subgraph "libx xfs"
XFS["xFsReqSubmit"]
POOL["Thread Pool"]
CB["Callback (on event loop)"]
end
subgraph "Thread Pool (via work())"
WORK["xpp::work()"]
PREAD["::pread / ::pwrite / ::fsync"]
end
OPEN --> ADAPT --> OA --> XFS
READ --> ADAPT --> RA --> XFS
CLOSE --> ADAPT --> CA --> XFS
FREE --> ADAPT --> SA & MA & UA & RNA & RDA --> XFS
XFS --> POOL --> CB
ALL --> WORK --> PREAD
style ADAPT fill:#50b86c,color:#fff
style XFS fill:#4a90d9,color:#fff
style WORK fill:#f5a623,color:#fff
Two execution paths
read(buf, len, offset) read_all()
│ │
▼ ▼
adapt<ssize_t, FsReadAdapter> xpp::work([fd]{ pread loop })
│ │
▼ ▼
xFsReqSubmit (xfs thread pool) xWorkSubmit (xpp thread pool)
│ │
▼ ▼
callback → PromiseResolver result → PromiseResolver
Single-operation I/O (read, write, open, close) goes through xFsReqSubmit — libx's xfs thread pool. Multi-step operations (read_all, write_all, sync_all) use xpp::work() to offload a blocking loop to the thread pool.
API Reference
File
| Method | Returns | Description |
|---|---|---|
open(path) | Promise<File> | Open read-only (O_RDONLY) |
open(path, flags, mode) | Promise<File> | Open with custom flags |
create(path, mode) | Promise<File> | Create/truncate write-only |
from_raw_fd(fd) | File | Take ownership of existing fd |
read(buf, len) | Promise<ssize_t> | Read from cursor (auto-advance) |
read(buf, len, offset) | Promise<ssize_t> | Read at offset (pread, cursor unchanged) |
read(Span<uint8_t>, offset) | Promise<ssize_t> | Read at offset (Span overload) |
write(buf, len) | Promise<ssize_t> | Write at cursor (auto-advance) |
write(buf, len, offset) | Promise<ssize_t> | Write at offset (pwrite, cursor unchanged) |
write(Span<const uint8_t>, offset) | Promise<ssize_t> | Write at offset (Span) |
write(string, offset) | Promise<ssize_t> | Write string at offset |
close() | Promise<void> | Async close |
sync_all() | Promise<void> | Flush to disk (fsync) |
stat() | Promise<Stat> | File metadata (fstat) |
read_all() | Promise<vector<uint8_t>> | Read entire file |
read_to_string() | Promise<string> | Read as text |
write_all(buf, len) | Promise<void> | Write all bytes from offset 0 |
raw_fd() | int | Raw file descriptor |
is_open() | bool | True if handle valid |
Free functions
| Function | Returns | Description |
|---|---|---|
stat(path) | Promise<Stat> | Stat by path |
exists(path) | Promise<bool> | Check if path exists |
read(path) | Promise<vector<uint8_t>> | Read entire file by path |
write(path, buf, len) | Promise<void> | Write file by path (create/truncate) |
create_dir(path, mode) | Promise<void> | Create directory |
remove_file(path) | Promise<void> | Delete file |
remove_dir(path) | Promise<void> | Remove empty directory |
rename(old, new) | Promise<void> | Rename file or directory |
Stat
| Field | Type | Description |
|---|---|---|
size | off_t | File size in bytes (-1 = error) |
mode | int | File mode (st_mode) |
mtime | uint64_t | Modification time (ms since epoch) |
ctime | uint64_t | Change time (ms since epoch) |
Usage Examples
Open, read, close
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto file = xpp::fs::File::open("data.txt").await();
char buf[4096];
ssize_t n = file.read(buf, sizeof(buf), 0).await();
// ~File() closes synchronously
Write with Span
uint8_t data[] = {0x01, 0x02, 0x03};
auto file = xpp::fs::File::create("out.bin").await();
file.write(xpp::Span<uint8_t>(data, 3), 0).await();
Read entire file
auto content = xpp::fs::read("config.json").await();
// content is std::vector<uint8_t>
Check existence
if (xpp::fs::exists("settings.json").await()) {
// file exists
}
Directory operations
xpp::fs::create_dir("output").await();
xpp::fs::write("output/data.txt", buf, len).await();
xpp::fs::rename("output/data.txt", "output/result.txt").await();
xpp::fs::remove_dir("output").await(); // fails if not empty
Coroutine
xpp::Promise<std::string> load_config() {
auto file = co_await xpp::fs::File::open("config.json");
co_return co_await file.read_to_string();
}
Chained with then()
xpp::fs::File::open("input.txt")
.then([](xpp::fs::File f) {
return f.read_to_string().then(
[f = std::move(f)](std::string content) {
// transform content
return content + "\n# appended";
});
})
.then([](std::string content) {
return xpp::fs::write("output.txt", content.data(), content.size());
})
.await();
Comparison
| Feature | xpp::fs::File | tokio::fs::File | std::fstream |
|---|---|---|---|
| Async | Promise + thread pool | async + thread pool | blocking |
| Offset | explicit (pread/pwrite) | file cursor (seek) | file cursor (seek) |
| Buffer | void* / Span<uint8_t> | &mut [u8] / &[u8] | char* / stream ops |
| RAII close | sync close in dtor | close on drop (async) | close on dtor |
| Error | negative ssize_t | Result<T, io::Error> | stream state bits |
| Directory ops | create_dir / remove_dir / rename | tokio::fs::create_dir etc. | std::filesystem |
| Compose with async | .then() / co_await / all() / race() | .await / tokio::join! / tokio::select! | N/A |
| C++ standard | C++11 | N/A | C++11 |
Implementation Notes
FsAdapter lifecycle
Each adapter inherits FsAdapterBase which owns an xFsReq:
adapt<T, FsXxxAdapter>(args...)
→ AdapterPromiseNode<T, FsXxxAdapter> (allocates once)
→ FsXxxAdapter(resolver, args...) (embedded, no separate alloc)
→ xFsReqSubmit(&m_req) (submits to xfs thread pool)
→ callback on event loop thread
→ resolver.resolve(result)
→ ~FsXxxAdapter: if !done, xFsReqCancel
2 heap allocations per operation: AdapterPromiseNode + Arc<ResolveState>. Same as TimerAdapter and WorkAdapter.
RAII close
~File() { close_sync(); }
void close_sync() {
if (m_open && m_fd >= 0) {
xFsReq req = {.op = xFsOpClose, .file = fd, .cb = nullptr};
xFsReqSubmit(&req); // cb=NULL → synchronous
}
}
cb = nullptr makes xFsReqSubmit block until the operation completes. close(fd) is near-instant (no disk I/O), so this is safe to call from a destructor.
read_all / write_all use work() not adapt()
Single read/write go through xFsReqSubmit (one shot). But read_all needs to loop (fstat → allocate → pread loop). Implementing this as a chain of individual read() Promises would be complex and slow. Instead, xpp::work() offloads the entire loop to the thread pool in one shot.
rename: buffer reuse for new path
xFsReq uses buf + offset (length) to pass the new path for rename. FsRenameAdapter stores the new path in a std::string m_new_path member to keep it alive for the duration of the async request.
I/O
Introduction
xpp::io provides reactive async I/O for non-blocking file descriptors, plus a structured error type shared across all I/O modules (net, fs, etc.).
#include <xpp/io/async_fd.h>
#include <xpp/io/error.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, sv);
xpp::io::AsyncFd io(sv[0]);
write(sv[1], "hello", 5);
char buf[64] = {};
ssize_t n = xpp::io::read(io, buf, sizeof(buf)).await();
Modules
-
AsyncFd — Reactive I/O wrapper: register fd once,
readable()/writable()asPromise<void>, fast-pathread()/write(). -
I/O Error —
io::Error: niche-optimized (4-byte) error type withErrorKind,raw_os_error(),raw_xerrno(). Mirrors Rust'sstd::io::Error. -
Utilities —
read_allandcopy: duck-typed template functions. Coroutine loops with 8KB stack buffers (C++20) or struct+move fallback (C++11). -
BufReader —
BufReader<R>: buffered async reader. Reduces per-call Promise overhead for small reads. -
Split —
BufWriter<W>: buffered async writer. Coalesces small writes, explicitflush().
AsyncFd
Introduction
xpp::io::AsyncFd provides reactive async I/O for non-blocking file descriptors. It registers an fd with the event loop once (edge-triggered, Read|Write), tracks readiness internally, and provides readable()/writable() as Promise<void>.
Free functions read()/write() combine a fast-path syscall (zero Promise overhead when data is available) with a readiness wait on EAGAIN.
Example — .await()
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, sv);
xpp::io::AsyncFd io(sv[0]);
// Fast path: data already available
write(sv[1], "hello", 5);
char buf[64] = {};
ssize_t n = xpp::io::read(io, buf, sizeof(buf)).await();
// n == 5
close(sv[0]); close(sv[1]);
Example — co_await (C++20)
xpp::Promise<void> read_socket() {
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0, sv);
xpp::io::AsyncFd io(sv[0]);
write(sv[1], "hello", 5);
char buf[64] = {};
ssize_t n = co_await xpp::io::read(io, buf, sizeof(buf));
// n == 5
close(sv[0]); close(sv[1]);
}
Design Philosophy
-
Register once, not per operation —
AsyncFdregisters withxEventAddonce in the constructor. Operations check readiness bools first (fast path), only storing aPromiseResolverwhen EAGAIN occurs. NoxEventAdd/xEventDelchurn. -
Fast path: zero Promise overhead —
read()/write()try the syscall immediately. If data is available (the common case), the result is returned viaresolve(n)with no Promise chain, no event registration, no waiting. -
Adapter pattern, not custom PromiseNode —
readable()/writable()useadapt<void, AsyncReadAdapter>(). The adapter stores aPromiseResolver<void>inAsyncFd's waiter slot. Whenon_eventfires, it callsresolver.resolve(), which triggers the waker inResolveState. Same pattern asTimerAdapter,WorkAdapter,FsOpenAdapter. -
Single-threaded — All operations run on the event loop thread. Plain
boolfor readiness, no atomics or mutex. -
Does not own fd —
AsyncFdregisters/deregisters with the event loop but does NOT::close(fd). The caller owns the fd.
Architecture
read(io, buf, len)
│
├── recv(fd, buf, len) → n >= 0
│ └── resolve(n) ← fast path, zero overhead
│
└── recv returns EAGAIN
└── io.readable()
├── m_readable == true?
│ └── resolve() ← already ready, no wait
└── store PromiseResolver in m_read_waiter
└── on_event fires (fd readable)
└── m_read_waiter.resolve()
└── .then(recv) retries
AsyncFd (per-fd, registered once)
├── xEventSource (persistent, edge-triggered, Read|Write)
├── bool m_readable / m_writable
├── PromiseResolver<void> m_read_waiter / m_write_waiter
└── on_event callback:
if Read: set m_readable or resolve m_read_waiter
if Write: set m_writable or resolve m_write_waiter
API Reference
AsyncFd
| Method | Returns | Description |
|---|---|---|
AsyncFd(fd) | Register fd with event loop (edge-triggered, Read|Write) | |
readable() | Promise<void> | Resolve when fd is readable. Immediate if already ready |
writable() | Promise<void> | Resolve when fd is writable. Immediate if already ready |
close() | void | Deregister, wake pending waiters. Does NOT close fd |
fd() | int | Raw file descriptor |
is_closed() | bool | True after close() or move |
Free functions
| Function | Returns | Description |
|---|---|---|
read(io, buf, len) | Promise<ssize_t> | Async recv. Fast path: try immediately. EAGAIN: wait readable |
write(io, buf, len) | Promise<ssize_t> | Async send. Same fast/slow pattern |
Usage Examples
Basic read — .await()
xpp::io::AsyncFd io(fd);
char buf[1024];
ssize_t n = xpp::io::read(io, buf, sizeof(buf)).await();
Read with then() chain
xpp::io::read(io, buf, 1024).then([](ssize_t n) {
return n;
}).await();
Wait for readability without reading
io.readable().then([&]() {
// fd is readable
}).await();
Close wakes pending waiters
xpp::io::AsyncFd io(fd);
// ... later ...
io.close(); // any pending readable()/writable() resolves immediately
// caller still needs to ::close(fd)
Comparison
| Feature | xpp::io::AsyncFd | tokio PollEvented / IoSource |
|---|---|---|
| Registration | once (persistent) | once (persistent) |
| Readiness tracking | bool (single-thread) | atomic + mutex |
| Wait mechanism | PromiseResolver (Adapter) | Waker (custom PromiseNode) |
| Thread safety | single-thread | multi-thread |
| Fast path | try syscall, zero overhead | try syscall, zero overhead |
| fd ownership | caller owns | caller owns |
Implementation Notes
Edge-triggered readiness
xEventAdd uses edge-triggered by default. After recv returns EAGAIN, the fd is not readable. When data arrives, the edge fires and on_event is called. The readiness bool is set, and the next readable() call resolves immediately.
If on_event fires while a PromiseResolver is stored in m_read_waiter, the resolver is called directly (readiness is consumed, not stored as a bool).
PromiseResolver safety
PromiseResolver<void> holds ArcWeak<ResolveState>. If the Promise is destroyed before the event fires, ArcWeak::upgrade() fails → no-op. No use-after-free.
Move semantics
Move constructor/assignment re-registers with xEventAdd because the event callback's arg pointer must point to the new AsyncFd object. xEventDel on the old source, xEventAdd on the new. The old object becomes a tombstone (fd == -1).
I/O Error
Introduction
xpp::io::Error is a structured I/O error type, packed into 4 bytes. It mirrors Rust's std::io::Error — a categorical ErrorKind plus an optional OS errno or libx xErrno.
#include <xpp/io/error.h>
xpp::io::Error err = xpp::io::Error::from_errno(EADDRINUSE);
if (err.kind() == xpp::io::ErrorKind::AddrInUse) {
// handle address-in-use
}
printf("%s\n", err.message()); // "Address already in use"
API Reference
ErrorKind
| Variant | Meaning |
|---|---|
InvalidInput | Malformed input (bad address string, NULL arg) |
HostNotFound | DNS resolution returned no results |
AddrInUse | EADDRINUSE |
AddrNotAvailable | EADDRNOTAVAIL |
PermissionDenied | EACCES |
ConnectionRefused | ECONNREFUSED |
ConnectionReset | ECONNRESET |
BrokenPipe | EPIPE |
TimedOut | ETIMEDOUT |
Other | Other syscall error |
Error
| Method | Returns | Description |
|---|---|---|
kind() | ErrorKind | Categorical kind |
raw_os_error() | int | OS errno, or 0 if not from a syscall |
raw_xerrno() | xErrno | libx error code, or xErrno_Ok if not from libx |
message() | const char* | Human-readable (strerror or kind description) |
from_errno(e) | Error | Construct from OS errno |
from_kind(k) | Error | Construct from ErrorKind |
from_xerrno(e) | Error | Construct from libx xErrno |
io::Result<T>
template <class T> using Result = xpp::Result<T, Error>;
Convenience alias — mirrors Rust's std::io::Result<T>.
Encoding
sizeof(io::Error) == 4 (one int32_t). Three sources are distinguishable via bit patterns:
m_code range | Source | Accessor |
|---|---|---|
0x00000001 .. 0x3FFFFFFF | OS errno | raw_os_error() |
0x40000000 .. 0x7FFFFFFF | libx xErrno (bit 30 set) | raw_xerrno() |
0x80000000 .. 0xFFFFFFFF | Custom ErrorKind (negative) | kind() |
0x00000000 | Niche (Ok sentinel) | — |
Bit 30 (0x40000000) is the xErrno flag. Errno and xErrno values are both small (< 256). Bit 31 (sign) separates custom kinds.
Usage Examples
From errno
xpp::io::Error err = xpp::io::Error::from_errno(ECONNREFUSED);
err.kind(); // ErrorKind::ConnectionRefused
err.raw_os_error(); // ECONNREFUSED (61 on macOS)
err.message(); // "Connection refused"
From xErrno
xErrno libx_err = xErrno_DnsNotFound;
xpp::io::Error err = xpp::io::Error::from_xerrno(libx_err);
err.kind(); // ErrorKind::HostNotFound
err.raw_xerrno(); // xErrno_DnsNotFound
err.raw_os_error(); // 0 (not from a syscall)
From ErrorKind
xpp::io::Error err = xpp::io::Error::from_kind(xpp::io::ErrorKind::InvalidInput);
err.kind(); // ErrorKind::InvalidInput
err.raw_os_error(); // 0
err.raw_xerrno(); // xErrno_Ok
With io::Result — .await()
auto r = xpp::net::UdpSocket::bind("127.0.0.1:9090").await();
if (r.is_err()) {
auto e = r.unwrap_err();
if (e.kind() == xpp::io::ErrorKind::AddrInUse) {
// port already taken
}
}
With io::Result — co_await (C++20)
xpp::Promise<void> bind_or_retry(const char *addr) {
auto r = co_await xpp::net::UdpSocket::bind(addr);
if (r.is_err()) {
auto e = r.unwrap_err();
if (e.kind() == xpp::io::ErrorKind::AddrInUse) {
co_await xpp::after(1000);
}
co_return;
}
auto sock = std::move(r).unwrap();
}
I/O Utilities
Introduction
xpp::io::read_all and xpp::io::copy are template utility functions that work on any type with read(void*, size_t) → Promise<ssize_t> and write(const void*, size_t) → Promise<ssize_t> — duck-typed, no traits or inheritance required. Both use an 8KB buffer (matching Rust's DEFAULT_BUF_SIZE).
C++20: coroutine loops with stack buffers. C++11 + XPP_FIBER: fiber .await() loops. C++11 without fiber: struct+move fallback with xpp::Shared heap buffer.
Example — .await()
#include <xpp/io/utils.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
// Copy from TcpStream to File
auto stream = xpp::net::TcpStream::connect("127.0.0.1:9090").await().unwrap();
auto file = xpp::fs::File::create("output.bin").await();
xpp::io::copy(stream, file).await();
Example — co_await (C++20)
auto stream = co_await xpp::net::TcpStream::connect("127.0.0.1:9090");
auto file = co_await xpp::fs::File::create("output.bin");
co_await xpp::io::copy(stream, file);
API Reference
read_all
template <class R>
Promise<std::vector<uint8_t>> read_all(R &reader);
Reads the entire byte stream into a vector. Stops when read returns ≤ 0 (EOF or error). Uses an 8KB buffer.
The reader must have read(void*, size_t) → Promise<ssize_t>. Compatible with TcpStream, fs::File (cursor mode), and any user-defined type matching the signature.
copy
template <class R, class W>
Promise<void> copy(R &reader, W &writer);
Pipes all content from reader to writer. Uses an 8KB buffer.
The reader must have read(void*, size_t) → Promise<ssize_t>. The writer must have write(const void*, size_t) → Promise<ssize_t>.
Usage Examples
Read entire response — .await()
auto stream = xpp::net::TcpStream::connect("example.com:80").await();
stream.write("GET / HTTP/1.0\r\n\r\n", 18).await();
auto data = xpp::io::read_all(stream).await();
printf("%.*s\n", (int)data.size(), data.data());
Read entire response — co_await (C++20)
xpp::Promise<void> fetch() {
auto stream = co_await xpp::net::TcpStream::connect("example.com:80");
co_await stream.write("GET / HTTP/1.0\r\n\r\n", 18);
auto data = co_await xpp::io::read_all(stream);
printf("%.*s\n", (int)data.size(), data.data());
}
Copy from TcpStream to File — .await()
auto stream = xpp::net::TcpStream::connect("example.com:80").await();
stream.write("GET / HTTP/1.0\r\n\r\n", 18).await();
auto file = xpp::fs::File::create("response.txt").await();
xpp::io::copy(stream, file).await();
Copy from TcpStream to File — co_await (C++20)
xpp::Promise<void> download() {
auto stream = co_await xpp::net::TcpStream::connect("example.com:80");
co_await stream.write("GET / HTTP/1.0\r\n\r\n", 18);
auto file = co_await xpp::fs::File::create("response.txt");
co_await xpp::io::copy(stream, file);
}
Read from File cursor — .await()
auto file = xpp::fs::File::open("data.txt").await();
char buf[16];
ssize_t n = file.read(buf, sizeof(buf)).await(); // cursor advance
auto rest = xpp::io::read_all(file).await(); // cursor to EOF
Read from File cursor — co_await (C++20)
xpp::Promise<void> process() {
auto file = co_await xpp::fs::File::open("data.txt");
char buf[16];
ssize_t n = co_await file.read(buf, sizeof(buf));
auto rest = co_await xpp::io::read_all(file);
}
BufReader
Introduction
xpp::io::BufReader<R> wraps any AsyncReader with an internal 8KB buffer. Small reads copy from the buffer with zero I/O overhead; large reads (≥ 8KB) bypass the buffer entirely. Reduces per-call Promise overhead for byte-at-a-time parsing over TCP.
Satisfies the AsyncReader concept — composable with io::read_all, io::copy, or nested in another BufReader. Takes ownership of the inner reader via move semantics. Works with .await(), co_await (C++20), or .then() chains (C++11).
Example — .await()
#include <xpp/io/buf_reader.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto conn = xpp::net::TcpStream::connect("127.0.0.1:9090").await().unwrap();
xpp::io::BufReader<xpp::net::TcpStream> buf(std::move(conn));
// Small reads: copy from buffer, zero per-call Promise overhead
char c;
buf.read(&c, 1).await(); // fills buffer from TCP
buf.read(&c, 1).await(); // copies from buffer
Example — co_await (C++20)
auto conn = xpp::net::TcpStream::connect("127.0.0.1:9090").await().unwrap();
xpp::io::BufReader<xpp::net::TcpStream> buf(std::move(conn));
char c;
co_await buf.read(&c, 1); // fills buffer from TCP
co_await buf.read(&c, 1); // copies from buffer
How it works
read(buf, len)
│
├── buffer has data? → memcpy from m_buf, advance m_pos
│
├── len ≥ 8KB? → drain buffer + inner.read(buf, len) suspension
│
└── buffer empty → inner.read(m_buf, 8KB) suspension → memcpy to output
Internal state:
m_buf[8KB] stack-allocated buffer
m_pos next unread byte in buffer
m_filled total bytes in buffer (0 after refill)
The first read() call fills the buffer from the inner reader. Subsequent small reads drain it with zero additional I/O — memcpy only. When the buffer is exhausted, it refills from the inner reader.
Large reads (≥ 8KB) skip the buffer: any pending buffered data is drained first, then the remaining bytes are read directly from the inner reader. This avoids a double-copy for bulk transfers.
API Reference
| Method | Returns | Description |
|---|---|---|
BufReader(R reader) | Take ownership of reader. Move-only | |
read(buf, len) | Promise<ssize_t> | Buffered read. Resolves to bytes read (0 = EOF) |
inner() | R& | Access the inner reader (e.g., for close()) |
Usage Examples
Byte-at-a-time parsing — .await()
xpp::io::BufReader<xpp::net::TcpStream> buf(std::move(conn));
// Read 4-byte header
char header[4];
buf.read(header, 4).await();
// Read payload length from header
uint32_t len = ntohl(*reinterpret_cast<uint32_t *>(header));
// Read payload (may be large — bypasses buffer)
auto payload = std::make_shared<std::vector<char>>(len);
buf.read(payload->data(), len).await();
Byte-at-a-time parsing — co_await (C++20)
xpp::Promise<void> parse_frame(xpp::net::TcpStream conn) {
xpp::io::BufReader<xpp::net::TcpStream> buf(std::move(conn));
char header[4];
co_await buf.read(header, 4);
uint32_t len = ntohl(*reinterpret_cast<uint32_t *>(header));
auto payload = std::make_shared<std::vector<char>>(len);
co_await buf.read(payload->data(), len);
}
Compose with io::read_all — .await()
xpp::io::BufReader<xpp::net::TcpStream> buf(std::move(conn));
std::string method;
char c;
while (true) {
buf.read(&c, 1).await();
if (c == ' ') break;
method += c;
}
auto body = xpp::io::read_all(buf).await();
Compose with io::read_all — co_await (C++20)
xpp::Promise<void> read_request(xpp::net::TcpStream conn) {
xpp::io::BufReader<xpp::net::TcpStream> buf(std::move(conn));
std::string method; char c;
while (true) {
co_await buf.read(&c, 1);
if (c == ' ') break; method += c;
}
auto body = co_await xpp::io::read_all(buf);
}
Large bypass
Reads ≥ 8KB go directly to the inner reader. No double-buffering:
// .await()
xpp::io::BufReader<xpp::net::TcpStream> buf(std::move(conn));
char data[65536]; // 64KB — larger than buffer
buf.read(data, sizeof(data)).await(); // bypasses buffer entirely
// co_await (C++20)
xpp::Promise<void> download_chunk(xpp::net::TcpStream conn) {
xpp::io::BufReader<xpp::net::TcpStream> buf(std::move(conn));
char data[65536];
co_await buf.read(data, sizeof(data));
}
BufWriter
Introduction
xpp::io::BufWriter<W> wraps any AsyncWriter with an internal 8KB buffer. Small writes fill the buffer; when it's full, pending data is automatically flushed to the inner writer. Large writes (≥ 8KB) bypass the buffer entirely. Reduces per-call syscall overhead for message-building over TCP.
Satisfies the AsyncWriter concept — composable with io::copy. Takes ownership of the inner writer via move semantics. Works with .await(), co_await (C++20), or .then() chains (C++11).
IMPORTANT: flush() must be called explicitly before dropping. The destructor does NOT flush un-sent data — matching Rust's BufWriter behavior.
Example — .await()
#include <xpp/io/buf_writer.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto conn = xpp::net::TcpStream::connect("127.0.0.1:9090").await().unwrap();
xpp::io::BufWriter<xpp::net::TcpStream> buf(std::move(conn));
// Small writes accumulate — no syscalls yet
buf.write("HTTP/1.0 200 OK\r\n", 17).await();
buf.write("Content-Length: 5\r\n\r\n", 22).await();
buf.write("hello", 5).await();
// Must flush before dropping!
buf.flush().await();
Example — co_await (C++20)
auto conn = xpp::net::TcpStream::connect("127.0.0.1:9090").await().unwrap();
xpp::io::BufWriter<xpp::net::TcpStream> buf(std::move(conn));
co_await buf.write("HTTP/1.0 200 OK\r\n", 17);
co_await buf.write("Content-Length: 5\r\n\r\n", 22);
co_await buf.write("hello", 5);
co_await buf.flush();
How it works
write(buf, len)
│
├── len ≥ 8KB? → flush pending + inner.write(buf, len) suspension
│
├── buffer would overflow? → flush pending first
│
└── memcpy to m_buf, advance m_pos
flush()
└── m_pos > 0? → inner.write(m_buf, m_pos) suspension → m_pos = 0
~BufWriter()
└── does NOT flush (Rust behavior)
Small writes accumulate in the internal buffer with zero I/O. When the buffer would overflow, pending data is automatically flushed first, then the new data is buffered. Large writes (≥ 8KB) flush any pending data, then write directly to the inner writer — avoiding double-buffering.
The destructor does NOT call flush(). This matches Rust's behavior: the caller is responsible for flushing. If flush() is forgotten, data is silently discarded.
API Reference
| Method | Returns | Description |
|---|---|---|
BufWriter(W writer) | Take ownership of writer. Move-only | |
write(buf, len) | Promise<ssize_t> | Buffered write. May trigger auto-flush |
flush() | Promise<void> | Send all buffered data to inner writer |
inner() | W& | Access the inner writer (e.g., for close()) |
Usage Examples
Build HTTP response — .await()
xpp::io::BufWriter<xpp::net::TcpStream> buf(std::move(conn));
buf.write("HTTP/1.0 200 OK\r\n", 17).await();
buf.write("Content-Type: text/plain\r\n", 26).await();
buf.write("Content-Length: 5\r\n", 20).await();
buf.write("\r\n", 2).await();
buf.write("hello", 5).await();
buf.flush().await(); // Must flush — bytes still in buffer
Build HTTP response — co_await (C++20)
xpp::Promise<void> respond(xpp::net::TcpStream conn) {
xpp::io::BufWriter<xpp::net::TcpStream> buf(std::move(conn));
co_await buf.write("HTTP/1.0 200 OK\r\n", 17);
co_await buf.write("Content-Type: text/plain\r\n", 26);
co_await buf.write("Content-Length: 5\r\n", 20);
co_await buf.write("\r\n", 2);
co_await buf.write("hello", 5);
co_await buf.flush();
}
Large bypass — .await()
xpp::io::BufWriter<xpp::net::TcpStream> buf(std::move(conn));
// Small header — buffered
buf.write("data: ", 6).await();
// Large body — bypasses buffer
buf.write(data, len).await(); // writes ≥ 8KB go directly to inner writer
buf.flush().await();
Large bypass — co_await (C++20)
xpp::Promise<void> upload(xpp::net::TcpStream conn, const void *data, size_t len) {
xpp::io::BufWriter<xpp::net::TcpStream> buf(std::move(conn));
co_await buf.write("data: ", 6);
co_await buf.write(data, len);
co_await buf.flush();
}
Close after flush
// .await()
buf.flush().await();
buf.inner().close(); // close the underlying TcpStream
// co_await (C++20)
co_await buf.flush();
buf.inner().close();
Take
Introduction
xpp::io::Take<R> wraps any AsyncReader with a byte limit. Once the limit is reached, read() returns 0 (EOF) regardless of whether the inner reader has more data. Essential for HTTP Content-Length parsing and protocol frame boundaries.
Satisfies the AsyncReader concept — composable with io::read_all, io::copy, BufReader. Takes ownership via move semantics.
Example — .await()
#include <xpp/io/take.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto conn = xpp::net::TcpStream::connect("127.0.0.1:9090").await().unwrap();
xpp::io::Take<xpp::net::TcpStream> body(std::move(conn), 128);
auto data = xpp::io::read_all(body).await(); // reads exactly 128 bytes, then EOF
Example — co_await (C++20)
auto conn = xpp::net::TcpStream::connect("127.0.0.1:9090").await().unwrap();
xpp::io::Take<xpp::net::TcpStream> body(std::move(conn), 128);
auto data = co_await xpp::io::read_all(body);
How it works
Take<R> { m_reader, m_remaining }
read(buf, len):
m_remaining == 0? → return 0 (EOF)
m_remaining > 0? → cap len to m_remaining, forward to inner
inner returns n → m_remaining -= n, return n
A simple counter tracks remaining bytes. Each read() delegates to the inner reader with a capped length. Once the counter hits zero, all subsequent reads return 0 without touching the inner reader.
API Reference
| Method | Returns | Description |
|---|---|---|
Take(R reader, size_t limit) | Take ownership and set byte limit | |
read(buf, len) | Promise<ssize_t> | Read up to len bytes, capped by remaining limit |
remaining() | size_t | Bytes remaining before EOF |
Usage Examples
HTTP body with Content-Length — .await()
xpp::io::Take<xpp::net::TcpStream> body(std::move(conn), content_length);
auto data = xpp::io::read_all(body).await();
// data.size() ≤ content_length
HTTP body with Content-Length — co_await (C++20)
xpp::Promise<void> read_body(xpp::net::TcpStream conn, size_t content_length) {
xpp::io::Take<xpp::net::TcpStream> body(std::move(conn), content_length);
auto data = co_await xpp::io::read_all(body);
}
Protocol frame boundary — .await()
char header[4];
conn.read(header, 4).await();
uint32_t body_len = ntohl(*reinterpret_cast<uint32_t *>(header));
xpp::io::Take<xpp::net::TcpStream> body(std::move(conn), body_len);
auto data = xpp::io::read_all(body).await();
Protocol frame boundary — co_await (C++20)
xpp::Promise<void> read_frame(xpp::net::TcpStream conn) {
char header[4];
co_await conn.read(header, 4);
uint32_t body_len = ntohl(*reinterpret_cast<uint32_t *>(header));
xpp::io::Take<xpp::net::TcpStream> body(std::move(conn), body_len);
auto data = co_await xpp::io::read_all(body);
}
Read limit hits zero — .await()
xpp::io::Take<xpp::net::TcpStream> limit(std::move(conn), 5);
char buf[10];
ssize_t n1 = limit.read(buf, 5).await(); // n1 == 5, remaining == 0
ssize_t n2 = limit.read(buf, 10).await(); // n2 == 0 (EOF)
Read limit hits zero — co_await (C++20)
xpp::io::Take<xpp::net::TcpStream> limit(std::move(conn), 5);
char buf[10];
ssize_t n1 = co_await limit.read(buf, 5); // n1 == 5, remaining == 0
ssize_t n2 = co_await limit.read(buf, 10); // n2 == 0 (EOF)
Empty
Introduction
xpp::io::Empty is an always-EOF async reader. Every call to read() returns 0 immediately. Useful for testing, placeholder values, and situations where a reader is expected but no data is available.
Satisfies the AsyncReader concept. C++11-compatible (uses xpp::resolve(0)).
Example — .await()
#include <xpp/io/empty.h>
xpp::io::Empty e;
ssize_t n = e.read(nullptr, 10).await();
// n == 0
Example — co_await (C++20)
xpp::io::Empty e;
ssize_t n = co_await e.read(nullptr, 10);
// n == 0
How it works
Empty is a simple struct whose read() returns xpp::resolve(0) — an immediately-resolved Promise returning 0 bytes. No I/O, no state, no coroutine overhead.
Use the empty() factory function for a cleaner API:
auto e = xpp::io::empty();
API Reference
| Method | Returns | Description |
|---|---|---|
empty() | Empty | Factory returning an always-EOF reader |
Usage Examples
As default reader parameter — .await()
auto e = xpp::io::empty();
auto data = xpp::io::read_all(e).await();
// data is empty vector
As default reader parameter — co_await (C++20)
template <AsyncReader R>
xpp::Promise<void> process(R &reader) {
auto data = co_await xpp::io::read_all(reader);
}
auto e = xpp::io::empty();
process(e).await();
Combined with Take
// .await()
xpp::io::Take<xpp::io::Empty> limit(xpp::io::empty(), 0);
ssize_t n = limit.read(nullptr, 10).await(); // n == 0
// co_await (C++20)
xpp::io::Take<xpp::io::Empty> limit(xpp::io::empty(), 0);
ssize_t n = co_await limit.read(nullptr, 10); // n == 0
Sink
Introduction
xpp::io::Sink is a write-discarding async writer. Every call to write() returns len immediately (the data is silently discarded). Useful for benchmarks, /dev/null equivalents, and situations where a writer is expected but the output is not needed.
Satisfies the AsyncWriter concept — composable with io::copy and BufWriter. C++11-compatible (uses xpp::resolve(len)).
Example — .await()
#include <xpp/io/sink.h>
xpp::io::Sink s;
ssize_t n = s.write("hello", 5).await();
// n == 5 (data discarded)
How it works
Sink is a simple struct whose write() returns xpp::resolve(static_cast<ssize_t>(len)) — an immediately-resolved Promise reporting that all bytes were "written". No I/O, no state, no coroutine overhead.
Use the sink() factory function:
auto s = xpp::io::sink();
API Reference
| Method | Returns | Description |
|---|---|---|
sink() | Sink | Factory returning a write-discarding writer |
Usage Examples
Benchmark copy throughput — .await()
auto s = xpp::io::sink();
xpp::io::copy(reader, s).await(); // measures pure read throughput
Benchmark copy throughput — co_await (C++20)
xpp::Promise<void> benchmark_copy(xpp::io::AsyncReader auto &reader) {
auto s = xpp::io::sink();
co_await xpp::io::copy(reader, s);
}
Discard unwanted output — .await()
char header[4];
conn.read(header, 4).await();
auto s = xpp::io::sink();
xpp::io::copy(conn, s).await(); // read and discard remaining data
Discard unwanted output — co_await (C++20)
xpp::Promise<void> parse_request(xpp::net::TcpStream conn) {
char header[4];
co_await conn.read(header, 4);
auto s = xpp::io::sink();
co_await xpp::io::copy(conn, s);
}
As default writer parameter
// .await()
auto s = xpp::io::sink();
xpp::io::copy(reader, s).await();
// co_await (C++20)
template <AsyncWriter W>
xpp::Promise<void> log_or_discard(xpp::io::AsyncReader auto &reader, W &writer) {
co_await xpp::io::copy(reader, writer);
}
auto s = xpp::io::sink();
log_or_discard(reader, s).await();
Duplex
Introduction
xpp::io::duplex(size) creates a pair of connected DuplexStreams backed by two internal ring buffers. Each half satisfies both AsyncReader and AsyncWriter — writing to one side makes data readable on the other. Like tokio's DuplexStream or Go's net.Pipe.
Works with .await(), co_await (C++20 coroutines), or .then() chains (C++11). Single-threaded — no atomics or mutex.
Example — .await()
#include <xpp/io/duplex.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [a, b] = xpp::io::duplex(4096);
// a → b and b → a simultaneously
a.write("ping", 4).await();
char buf[8];
b.read(buf, 8).await(); // buf = "ping"
Example — co_await (C++20)
auto [a, b] = xpp::io::duplex(4096);
co_await a.write("ping", 4);
char buf[8];
co_await b.read(buf, 8); // buf = "ping"
How it works
duplex(4096)
├── side[0]: A's receive buffer (B writes here, A reads)
├── side[1]: B's receive buffer (A writes here, B reads)
├── DuplexStream(idx=0) → reads from side[0], writes to side[1]
└── DuplexStream(idx=1) → reads from side[1], writes to side[0]
write(buf, len):
copy to opposite side's buffer → wake opposite reader
read(buf, len):
copy from own buffer → wake opposite writer
close():
set opposite side's closed flag → wake opposite reader (EOF)
Writing suspends when the destination buffer is full; reading suspends when the source buffer is empty. Both use PromiseResolver for wakeup — no event loop needed, resolution is synchronous from the opposite half.
API Reference
| Method | Returns | Description |
|---|---|---|
duplex(size) | pair<DuplexStream, DuplexStream> | Create connected pair with size-byte ring buffers per direction |
DuplexStream::read(buf, len) | Promise<ssize_t> | Read from this side. Suspends when empty, returns 0 on EOF |
DuplexStream::write(buf, len) | Promise<ssize_t> | Write to the other side. Suspends when buffer full |
DuplexStream::flush() | Promise<void> | No-op (data flows directly to buffer) |
DuplexStream::close() | void | Close write direction. Other side sees EOF on read |
Usage Examples
Bidirectional echo — .await()
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [client, server] = xpp::io::duplex(256);
client.write("ping", 4).await();
char buf[8];
server.read(buf, 8).await(); // server reads "ping"
server.write("pong", 4).await(); // server responds
client.read(buf, 8).await(); // client reads "pong"
Bidirectional echo — co_await (C++20)
auto [client, server] = xpp::io::duplex(256);
co_await client.write("ping", 4);
char buf[8];
co_await server.read(buf, 8); // server reads "ping"
co_await server.write("pong", 4); // server responds
co_await client.read(buf, 8); // client reads "pong"
Test I/O utilities without TCP — .await()
auto [a, b] = xpp::io::duplex(4096);
// Producer
b.write("hello world", 11).await();
b.close();
// Consumer with BufReader
xpp::io::BufReader buf{std::move(a)};
auto data = xpp::io::read_all(buf).await();
// data == "hello world"
Test I/O utilities without TCP — co_await (C++20)
auto [a, b] = xpp::io::duplex(4096);
auto wp = [](xpp::io::DuplexStream &w) -> xpp::Promise<void> {
co_await w.write("hello world", 11);
w.close();
};
auto rp = [](xpp::io::DuplexStream &r) -> xpp::Promise<void> {
xpp::io::BufReader buf{std::move(r)};
auto data = co_await xpp::io::read_all(buf);
};
Simplex
Introduction
xpp::io::simplex(size) creates a unidirectional pipe — a SimplexReader and SimplexWriter sharing a single ring buffer. Simpler than duplex() which provides two buffers for bidirectional communication. Like Go's io.Pipe.
Works with .await(), co_await (C++20 coroutines), or .then() chains (C++11). Single-threaded.
Example — .await()
#include <xpp/io/simplex.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [reader, writer] = xpp::io::simplex(4096);
writer.write("hello", 5).await();
writer.close();
char buf[8];
reader.read(buf, 8).await(); // buf = "hello"
reader.read(buf, 8).await(); // returns 0 (EOF)
Example — co_await (C++20)
auto [reader, writer] = xpp::io::simplex(4096);
co_await writer.write("hello", 5);
writer.close();
char buf[8];
co_await reader.read(buf, 8); // buf = "hello"
co_await reader.read(buf, 8); // returns 0 (EOF)
How it works
simplex(4096)
├── ring buffer (4096 bytes)
├── SimplexReader → reads from buffer, returns 0 on EOF
└── SimplexWriter → writes to buffer, close() signals EOF
write(buf, len):
copy to buffer → wake reader
read(buf, len):
copy from buffer → wake writer
close():
set closed flag → wake reader (EOF)
Writing suspends when the buffer is full; reading suspends when the buffer is empty.
API Reference
| Method | Returns | Description |
|---|---|---|
simplex(size) | pair<SimplexReader, SimplexWriter> | Create unidirectional pipe with size-byte buffer |
SimplexReader::read(buf, len) | Promise<ssize_t> | Read from pipe. Suspends when empty, 0 on EOF |
SimplexWriter::write(buf, len) | Promise<ssize_t> | Write to pipe. Suspends when buffer full |
SimplexWriter::flush() | Promise<void> | No-op |
SimplexWriter::close() | void | Signal EOF to reader |
Usage Examples
Testing read_all — .await()
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [reader, writer] = xpp::io::simplex(256);
writer.write("hello world", 11).await();
writer.close();
auto data = xpp::io::read_all(reader).await();
// data.size() == 11, data == "hello world"
Testing read_all — co_await (C++20)
auto [reader, writer] = xpp::io::simplex(256);
co_await writer.write("hello world", 11);
writer.close();
auto data = co_await xpp::io::read_all(reader);
Testing copy — .await()
auto [reader, writer] = xpp::io::simplex(256);
writer.write("ping", 4).await();
writer.close();
xpp::io::Empty dst;
xpp::io::copy(reader, dst).await(); // reads "ping" and discards
Testing copy — co_await (C++20)
auto [reader, writer] = xpp::io::simplex(256);
co_await writer.write("ping", 4);
writer.close();
xpp::io::Empty dst;
co_await xpp::io::copy(reader, dst);
Repeat
Introduction
xpp::io::Repeat is an infinite repeating byte reader. Every call to read() fills the buffer with the same byte and returns len. Never returns EOF — useful for benchmarks and generating padding data.
C++11-compatible (no coroutines). Satisfies the AsyncReader concept.
Example — .await()
#include <xpp/io/repeat.h>
auto r = xpp::io::repeat('A');
char buf[16];
ssize_t n = r.read(buf, sizeof(buf)).await();
// n == 16, buf = "AAAAAAAAAAAAAAAA"
How it works
Repeat stores a single byte and returns xpp::resolve(len) after filling the buffer with std::memset. No I/O, no state changes, no coroutine overhead.
API Reference
| Method | Returns | Description |
|---|---|---|
repeat(byte = 0) | Repeat | Create a repeat reader for the given byte |
Usage Examples
Benchmark throughput — .await()
auto r = xpp::io::repeat();
auto s = xpp::io::sink();
xpp::io::copy(r, s).await(); // measures pure reader + copy throughput
Benchmark throughput — co_await (C++20)
auto r = xpp::io::repeat();
auto s = xpp::io::sink();
co_await xpp::io::copy(r, s);
Padding generation
// .await()
auto zeros = xpp::io::repeat(0);
char pad[1024];
zeros.read(pad, 1024).await(); // 1KB of zeros
// co_await (C++20)
auto zeros = xpp::io::repeat(0);
char pad[1024];
co_await zeros.read(pad, 1024);
Join
Introduction
xpp::io::join(reader, writer) combines an AsyncReader and AsyncWriter into a single type that satisfies both concepts. Useful when you have separate read/write halves (e.g., from simplex) and need a single bidirectional handle.
C++11-compatible. Duck-typed — the combined type automatically satisfies both AsyncReader and AsyncWriter concepts.
Example — .await()
#include <xpp/io/join.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [reader, writer] = xpp::io::simplex(256);
auto joined = xpp::io::join(std::move(reader), std::move(writer));
// Now reads from reader and writes to writer through a single object
joined.write("hello", 5).await();
char buf[8];
joined.read(buf, 8).await(); // buf = "hello"
Example — co_await (C++20)
auto [reader, writer] = xpp::io::simplex(256);
auto joined = xpp::io::join(std::move(reader), std::move(writer));
co_await joined.write("hello", 5);
char buf[8];
co_await joined.read(buf, 8); // buf = "hello"
How it works
Join<R, W> stores the reader and writer by value (takes ownership). read() delegates to the inner reader; write() delegates to the inner writer; flush() and close() delegate to the inner writer. Zero overhead — all calls are inlined through the template.
API Reference
| Method | Returns | Description |
|---|---|---|
join(reader, writer) | Join<R, W> | Combine reader and writer into one type |
Join::read(buf, len) | Promise<ssize_t> | Forward to inner reader |
Join::write(buf, len) | Promise<ssize_t> | Forward to inner writer |
Join::flush() | Promise<void> | Forward to inner writer |
Join::close() | void | Forward to inner writer |
Usage Examples
Combine simplex halves — .await()
auto [reader, writer] = xpp::io::simplex(256);
auto conn = xpp::io::join(std::move(reader), std::move(writer));
conn.write("ping", 4).await();
char buf[8];
conn.read(buf, 8).await(); // reads "ping" from self
Combine simplex halves — co_await (C++20)
auto [reader, writer] = xpp::io::simplex(256);
auto conn = xpp::io::join(std::move(reader), std::move(writer));
co_await conn.write("ping", 4);
char buf[8];
co_await conn.read(buf, 8);
Separate read/write TcpStreams — .await()
auto reader = popen_tcp_stream.read();
auto writer = popen_tcp_stream.write();
auto conn = xpp::io::join(reader, writer);
xpp::io::copy(conn, file).await();
Separate read/write TcpStreams — co_await (C++20)
auto reader = popen_tcp_stream.read();
auto writer = popen_tcp_stream.write();
auto conn = xpp::io::join(reader, writer);
co_await xpp::io::copy(conn, file);
Split
Introduction
xpp::io::split(stream) splits any type satisfying both AsyncReader and AsyncWriter into independent ReadHalf<T> and WriteHalf<T>. Like tokio's io::split() — the two halves share the underlying stream via Arc<T> (atomic refcount).
Example — .await()
#include <xpp/io/split.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [reader, writer] = xpp::io::split(std::move(stream));
// Concurrent read + write on the same connection
xpp::all(reader.read(buf, 10), writer.write("hi", 2)).await();
Example — co_await (C++20)
auto [reader, writer] = xpp::io::split(std::move(stream));
auto [n, _] = co_await xpp::all(reader.read(buf, 10), writer.write("hi", 2));
How it works
io::split(stream)
└── Arc<T>::make(std::move(stream))
├── ReadHalf<T> { Arc<T> m_inner } → read(buf, len)
└── WriteHalf<T> { Arc<T> m_inner } → write(buf, len), flush()
Both halves hold a copy of the Arc<T> refcount.
The underlying stream is destroyed when the last half drops.
API Reference
| Method | Returns | Description |
|---|---|---|
split(stream) | pair<ReadHalf<T>, WriteHalf<T>> | Split into independent read/write halves |
Usage Examples
Split TcpStream — .await()
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [reader, writer] = xpp::io::split(std::move(conn));
char buf[16];
// Read first
ssize_t n = reader.read(buf, sizeof(buf)).await();
// Then echo back
writer.write(buf, static_cast<size_t>(n)).await();
Split TcpStream — co_await (C++20)
xpp::Promise<void> echo(xpp::net::TcpStream conn) {
auto [reader, writer] = xpp::io::split(std::move(conn));
char buf[16];
ssize_t n = co_await reader.read(buf, sizeof(buf));
co_await writer.write(buf, static_cast<size_t>(n));
}
Channels
xpp provides five channel primitives in xpp::sync, plus the Notify notification primitive:
| Channel | Pattern | Capacity | Use Case |
|---|---|---|---|
| oneshot | 1→1, single-use | 1 | Deferred result, async callback |
| mpsc | M→1 | bounded / unbounded | Work queues, task dispatch |
| broadcast | M→N | bounded | Event fan-out, shutdown signals |
| watch | M→N | 1 (latest) | Configuration hot-reload, state observation |
| notify | — | — | Wake-up signal, barrier coordination |
Choosing a channel
One value?
/ \
yes no
│ │
Single-use? All consumers
/ \ see all values?
yes no / \
│ │ yes no
oneshot watch │ │
broadcast mpsc
- oneshot: Send a single value once. Think "async return value".
- watch: Keep only the latest value. Think "config that changes over time".
- broadcast: Every consumer sees every value. Think "event stream".
- mpsc: Each value consumed exactly once. Think "work queue".
All channels work with .await() (C++11 + fiber), co_await (C++20), and .then() (C++11 callback chains).
Thread safety
All channels support both single-threaded and multi-threaded usage — shared
state always uses Arc<T> (atomic refcount); the old XPP_MT switch was removed.
Multi-threaded tests exist for all channels (*_mt_test.cpp).
RAII close
All channels use RAII close: when the last Sender handle is dropped, the channel
automatically closes, notifying any blocked receivers. Explicit close() is also
available for early shutdown.
oneshot
Single-value, single-use channel. Think "async return value".
Sender ──(value)──▶ Receiver
Once send() is called, the Receiver's promise resolves. Calling send()
more than once is safe — only the first call takes effect (atomic CAS).
Example — .await()
#include <xpp/promise.h>
#include <xpp/sync/oneshot.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [tx, rx] = xpp::sync::oneshot::channel<int>();
std::thread worker([tx = std::move(tx)]() mutable {
tx.send(42);
});
int result = std::move(rx).recv().await(); // resolves when worker sends
// result == 42
With xpp::fiber() — non-blocking:
xpp::fiber([]() {
auto [tx, rx] = xpp::sync::oneshot::channel<int>();
std::thread([tx = std::move(tx)]() { tx.send(42); }).detach();
int result = std::move(rx).recv().await(); // fiber suspends
return result;
}).then([](int v) { printf("got %d\n", v); });
Example — co_await (C++20)
xpp::Promise<int> await_result() {
auto [tx, rx] = xpp::sync::oneshot::channel<int>();
std::thread([tx = std::move(tx)]() { tx.send(42); }).detach();
co_return co_await std::move(rx).recv();
}
API
| Type | Method | Description |
|---|---|---|
Sender<T> | send(T value) | Send the value. Idempotent. |
Receiver<T> | recv() && | Returns Promise<T>. Resolves when send() is called. |
Thread safety
Sender::send() is inherently thread-safe — PromiseResolver::resolve() uses
Arc<ArcWeak> + atomic CAS internally. The Sender can be moved to another
thread and send() called from there without additional synchronization.
mpsc
Multi-producer, single-consumer channel. Each value is consumed exactly once.
Sender ──┬──▶ [v0][v1][v2] ──▶ Receiver
Sender ──┘
Bounded and unbounded variants available:
| Variant | API | Backend |
|---|---|---|
| Bounded | channel<T>(cap) | Lock-free ring buffer (pre-allocated slots) |
| Unbounded | channel<T>() | Lock-free linked list (heap-allocated per send) |
Bounded channel — .await()
#include <xpp/sync/mpsc.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [tx, rx] = xpp::sync::mpsc::channel<int>(16);
// Async: .await() suspends (fiber) or blocks + drives loop
tx.send(42).await();
// Sync: returns immediately, fails when full
auto r = tx.try_send(99);
if (r.is_err()) { /* Full or Closed */ }
// Clone the sender for multiple producers
auto tx2 = tx;
tx2.send(10).await();
// Receive
auto v = rx.recv().await(); // Option<T> — none if closed
auto v = rx.try_recv(); // Result<T, TryRecvError>
With fiber — non-blocking:
xpp::fiber([]() {
auto [tx, rx] = xpp::sync::mpsc::channel<int>(4);
std::thread producer([tx = std::move(tx)]() mutable {
for (int i = 0; i < 10; i++) tx.send(i).await(); // blocks when full
}).detach();
for (int i = 0; i < 10; i++) {
auto v = rx.recv().await(); // fiber suspends when empty
printf("got %d\n", v.unwrap());
}
}).await();
Bounded channel — co_await (C++20)
auto [tx, rx] = xpp::sync::mpsc::channel<int>(16);
co_await tx.send(42);
auto tx2 = tx;
co_await tx2.send(10);
auto v = co_await rx.recv();
Unbounded channel — .await()
auto [tx, rx] = xpp::sync::mpsc::channel<int>(); // no capacity argument
tx.send(42); // void — never blocks
tx.try_send(99); // bool — always true
auto v = rx.recv().await(); // async receive
auto v = rx.try_recv(); // sync receive — Option<T>
Unbounded channel — co_await
auto [tx, rx] = xpp::sync::mpsc::channel<int>();
tx.send(42);
auto v = co_await rx.recv();
API
Bounded Sender<T>
| Method | Returns | Description |
|---|---|---|
send(T) | Promise<void> | Async send. Suspends when full. |
try_send(T) | Result<Void, TrySendError<T>> | Sync send. Fails with Full or Closed. |
close() | — | Explicitly close. Wakes all waiters. |
Bounded Receiver<T>
| Method | Returns | Description |
|---|---|---|
recv() | Promise<Option<T>> | Async receive. none when closed & empty. |
try_recv() | Result<T, TryRecvError> | Sync receive. |
Unbounded UnboundedSender<T>
| Method | Returns | Description |
|---|---|---|
send(T) | void | Always succeeds. |
try_send(T) | bool | Always returns true. |
Unbounded UnboundedReceiver<T>
| Method | Returns | Description |
|---|---|---|
recv() | Promise<Option<T>> | Async receive. |
try_recv() | Option<T> | Sync receive. |
Thread safety
- Bounded: lock-free send path (
fetch_addCAS). Multiple threads cantry_sendconcurrently. - Unbounded: lock-free linked list. Multiple threads can
sendconcurrently. - Both: receiver is single-consumer (no concurrent recv).
broadcast
Multi-producer, multi-consumer channel. Every consumer sees every value.
Sender ──┬──▶ [v0][v1][v2] ──▶ Receiver₁ (pos=2)
Sender ──┘ Receiver₂ (pos=1)
Values are retained until all receivers have read them, or evicted when the
bounded buffer wraps around. Receivers that fall behind get RecvError::Lagged.
Example — .await()
#include <xpp/sync/broadcast.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [tx, rx1] = xpp::sync::broadcast::channel<std::string>(16);
auto rx2 = tx.subscribe();
tx.send("hello");
tx.send("world");
// Both receivers see both values
auto v1 = rx1.recv().await(); // Ok("hello")
auto v2 = rx2.recv().await(); // Ok("hello")
auto v3 = rx1.recv().await(); // Ok("world")
auto v4 = rx2.recv().await(); // Ok("world")
// Late subscriber only sees future values
auto rx3 = tx.subscribe();
tx.send("!");
auto v5 = rx3.recv().await(); // Ok("!") — didn't see hello/world
Example — co_await (C++20)
auto [tx, rx1] = xpp::sync::broadcast::channel<std::string>(16);
auto rx2 = tx.subscribe();
tx.send("hello");
tx.send("world");
auto v1 = co_await rx1.recv(); // Ok("hello")
auto v2 = co_await rx2.recv(); // Ok("hello")
Handling lag
auto [tx, rx] = xpp::sync::broadcast::channel<int>(2);
tx.send(1);
tx.send(2);
tx.send(3); // buffer full → 1 is evicted
auto r = rx.recv().await();
if (r.is_err()) {
// RecvError::Lagged — values were lost
// Position auto-resets to current head, can continue
}
auto v = rx.recv().await(); // Ok(2)
auto v = rx.recv().await(); // Ok(3)
API
Sender<T>
| Method | Returns | Description |
|---|---|---|
send(T) | Promise<Result<size_t, SendError<T>>> | Send value. Returns receiver count. |
try_send(T) | Result<size_t, SendError<T>> | Synchronous send. |
subscribe() | Receiver<T> | Create new receiver (future values only). |
receiver_count() | size_t | Number of active receivers. |
len() | size_t | Number of buffered values. |
Receiver<T>
| Method | Returns | Description |
|---|---|---|
recv() | Promise<Result<T, RecvError>> | Next value, or Lagged/Closed. |
try_recv() | Result<T, TryRecvError> | Synchronous receive. |
Error types
| Type | Variant | Description |
|---|---|---|
RecvError | Lagged | Values were evicted before reading. |
Closed | All senders dropped, buffer empty. | |
TryRecvError | Empty | No value available. |
Closed | Channel empty and closed. | |
SendError<T> | NoReceiver(v) | No receivers subscribed; value returned. |
Thread safety
- Lock-free send path (mutex on
m_head/m_tailupdate only). - Receiver is single-consumer.
- Multiple senders (cloned Sender) supported.
watch
Single-value, version-tracked channel. Only the latest value is retained. Each receiver tracks which version it has "seen".
Sender ──(value)──▶ [latest] ──▶ Receiver₁ (seen=v2)
version++ Receiver₂ (seen=v1)
Use for configuration hot-reload, state observation, or any pattern where consumers want the latest value, not every intermediate value.
Example — .await()
#include <xpp/sync/watch.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto [tx, rx] = xpp::sync::watch::channel<std::string>("localhost:8080");
// On config change:
tx.send("0.0.0.0:9090");
// Wait for changes
while (true) {
auto r = rx.changed().await();
if (r.is_err()) break; // sender dropped
auto ref = rx.borrow_and_update(); // read + mark seen
std::cout << *ref << "\n";
}
Example — co_await (C++20)
auto [tx, rx] = xpp::sync::watch::channel<std::string>("localhost:8080");
tx.send("0.0.0.0:9090");
while (true) {
auto r = co_await rx.changed();
if (r.is_err()) break;
auto ref = rx.borrow_and_update();
std::cout << *ref << "\n";
}
"Seen" semantics
Initial: version=0, rx.seen=0 (initial value already "seen")
tx.send("hello") → version=1
rx.changed() → version≠seen → mark seen=1, return Ok
rx.changed() → version==seen → wait
tx.send("world") → version=2
rx.changed() → resume, seen=2, return Ok
borrow() reads without marking seen — changed() will still trigger.
borrow_and_update() reads AND marks seen — changed() will wait for next.
API
Sender<T>
| Method | Returns | Description |
|---|---|---|
send(T) | Result<T, SendError<T>> | Replace value. Returns old value. |
borrow() | Ref<T> | Read-lock current value. |
subscribe() | Receiver<T> | New receiver (current value = seen). |
receiver_count() | size_t | Number of active receivers. |
is_closed() | bool | Whether channel is closed. |
closed() | Promise<void> | Resolves when all receivers dropped. |
Receiver<T>
| Method | Returns | Description |
|---|---|---|
changed() | Promise<Result<void, RecvError>> | Wait for unseen value, marks seen. |
has_changed() | Result<bool, RecvError> | Sync check, does NOT mark seen. |
borrow_and_update() | Ref<T> | Read + mark seen. |
borrow() | Ref<T> | Read, does NOT mark seen. |
Ref<T>
A read guard that holds an exclusive lock on the shared value. Prevents concurrent writes while the value is being read. In single-threaded builds, the lock is a no-op.
| Method | Returns | Description |
|---|---|---|
operator*() | const T& | Access the value. |
operator->() | const T* | Access members. |
Thread safety
loom::Mutex<T>protects the shared value (exclusive write + exclusive read).Ref<T>automatically releases the lock on destruction.- Cross-thread wakeup via Notify's
notify_waiters().
Notify
Reusable multi-waiter notification primitive. Unlike oneshot, Notify can be
used repeatedly — after notify_one() wakes a waiter, a subsequent call to
notified() will park the coroutine until the next notification.
Think "manual condvar": a sender can signal waiting coroutines without sending data.
Example — .await()
#include <xpp/sync/notify.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
xpp::sync::Notify n;
// Waiter 1: waits for signal
n.notified().await(); // suspends (fiber) or blocks + drives loop
// Waiter 2: also waits
n.notified().await();
// Sender: wake one
n.notify_one(); // wakes waiter 1 (or 2)
// Sender: wake all
n.notify_waiters(); // wakes all remaining waiters
With fiber — non-blocking:
xpp::fiber([]() {
xpp::sync::Notify n;
std::thread([&] { n.notify_one(); }).detach();
n.notified().await(); // fiber suspends, event loop continues
printf("woken up\n");
}).await();
Example — co_await (C++20)
xpp::sync::Notify n;
co_await n.notified(); // suspends until notify_one() or notify_waiters()
n.notify_one(); // wake one
n.notify_waiters(); // wake all
When notify arrives before notified()
If a worker thread calls notify_one() before any coroutine has called
notified(), the notification is accumulated as a pending count. The next
notified() call resolves immediately without suspending.
Notify n;
// Worker thread (no event loop)
std::thread([&] {
n.notify_one();
}).detach();
n.notified().await(); // resolves immediately — notification was pending
API
| Method | Returns | Description |
|---|---|---|
notified() | Promise<void> | Wait for the next notification. |
notify_one() | void | Wake one waiting coroutine. |
notify_waiters() | void | Wake ALL waiting coroutines. |
Internals
Uses loom::Mutex<std::vector<PromiseResolver<void>>> for the waiter list.
An atomic pending counter handles the "notify before notified" race.
notified() → m_pending > 0? → resolve immediately (lock-free fast path)
→ lock → push resolver → unlock → wait
notify_one() → lock → pop resolver → unlock → resolve
→ waiters empty? → m_pending++ (accumulate)
Thread safety
PromiseResolver::resolve() uses Arc<ArcWeak> + atomic CAS internally.
The pending counter is atomic. Cross-thread wakeup flows through
PromiseWaker::wake() → xEventLoopPost() for cross-thread notification.
Net
Introduction
xpp::net provides Promise-based async TCP, UDP, DNS, URL parsing, and TLS configuration — wrapping libx's C APIs into C++ types.
#include <xpp/net/tcp.h>
#include <xpp/net/udp.h>
#include <xpp/net/url.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
using xpp::net::TcpStream;
using xpp::net::TcpListener;
// TCP echo server + client in one chain
auto server = TcpListener::bind("127.0.0.1:9090").await().unwrap();
auto client_p = TcpStream::connect("127.0.0.1:9090").then([](TcpStream c) {
return c.write("hi", 2).then([c](ssize_t) mutable {
char buf[64];
return c.read(buf, 64);
});
});
client_p.await();
Design Philosophy
-
Promise-based, poll-driven — All async ops return
Promise<T>.wait()drives the event loop; no separate runtime or reactor thread. -
Fast-path syscall + EAGAIN readiness —
recv/send/recv_from/send_totry the syscall immediately. On EAGAIN, they wait for readiness viaAsyncFdand retry. Zero Promise overhead when data is available. -
adapt() for one-shot ops —
lookup_host()usesadapt<T, Adapter>()— the adapter starts the async op in its constructor, cancels in its destructor, and theAdapterPromiseNodeowns the adapter.TcpStream::connect()usesasync() + new(self-deleting adapter) because libx'sxTcpConnecthas no cancel API. -
TLS is transparent — Pass
Option<const TlsContext&> = nonetoTcpStream::connect()to enable TLS. libx'sxTcpConnectdoes the handshake; the resultingTcpStreamtransparently encrypts/decrypts. No separateTlsConntype. -
RAII everywhere —
TcpStream,TcpListener,UdpSocket,Url,TlsContextall close/free their underlying resources in destructors. Move-only. -
C++11-compatible — All headers compile as C++11.
std::pair+std::tieforrecv_fromresults (C++17 users may use structured bindings).
Architecture
TCP UDP DNS URL/TLS
├── TcpStream ├── UdpSocket ├── lookup_host() ├── Url (sync)
│ ├── xTcpConn (libx) │ ├── int m_fd │ └── adapt() │ └── xUrl
│ ├── AsyncFd (readiness) │ └── AsyncFd └── LookupHostAdapter ├── TlsConfig
│ ├── connect via async()+new └── bind/recv_from/send_to └── TlsContext
├── TcpListener (RAII)
│ ├── xTcpListener
│ └── accept via adapt()
└── TLS via TlsContext
Modules
- TCP —
TcpStreamandTcpListener: Promise-based async TCP. - UDP —
UdpSocket: async UDP from scratch (libx has no UDP API). - DNS —
lookup_host(): async hostname resolution. - URL —
Url: RAII wrapper aroundxUrlwith structured errors. - TLS —
TlsConfigandTlsContext: RAII TLS configuration.
bind methods return Promise<io::Result<T, io::Error>> — see I/O Error for the error type.
Comparison with tokio::net
| Aspect | xpp::net | tokio::net |
|---|---|---|
| Async model | Poll-based Promise + wait() | async fn + .await |
| TCP connect | Promise<TcpStream> (async()+new) | Future<Result<TcpStream>> |
| Readiness | AsyncFd (edge-triggered) | mio (edge-triggered) |
| Fast path | ::read + EAGAIN → readiness | read + EAGAIN → readiness |
| TLS | Option<const TlsContext&> to connect() | TlsConnector::connect() |
| DNS | lookup_host() → Promise<vector<SocketAddr>> | lookup_host() → Future<impl Iterator> |
| UDP | recv_from → Promise<pair<ssize_t, SocketAddr>> | recv_from → Future<Result<(usize, SocketAddr)>> |
| Bind | Async (Promise<io::Result<T>>, DNS for hostnames) | Async (ToSocketAddrs may resolve) |
| Error type | io::Error (4 bytes, niche-optimized) | std::io::Error (heap-allocated) |
| Threading | Single-threaded | Multi-threaded runtime |
TCP
Introduction
xpp::net::TcpStream and TcpListener provide Promise-based async TCP, wrapping libx's xTcpConn and xTcpListener. I/O uses io::AsyncFd for readiness — fast-path syscall + EAGAIN wait.
Example — .await()
Echo server with fiber:
#include <xpp/net/tcp.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
xpp::fiber([]() {
auto listener = xpp::net::TcpListener::bind("127.0.0.1:8080").await().unwrap();
auto [conn, addr] = listener.accept().await();
char buf[1024];
while (true) {
ssize_t n = conn.read(buf, sizeof(buf)).await();
if (n <= 0) break;
conn.write(buf, n).await();
}
}).await();
Example — co_await (C++20)
Same echo server in coroutine style:
#include <xpp/net/tcp.h>
xpp::Promise<void> echo_server() {
auto listener = (co_await xpp::net::TcpListener::bind("127.0.0.1:8080")).unwrap();
auto [conn, addr] = co_await listener.accept();
char buf[1024];
while (true) {
ssize_t n = co_await conn.read(buf, sizeof(buf));
if (n <= 0) break;
co_await conn.write(buf, static_cast<size_t>(n));
}
}
TcpStream
API Reference
| Method | Returns | Description |
|---|---|---|
connect("host:port", tls = none) | Promise<io::Result<TcpStream>> | Async connect ("IP:port" or "hostname:port", with optional TLS) |
connect(SocketAddr, tls = none) | Promise<io::Result<TcpStream>> | Connect by address (no DNS) |
read(buf, len) | Promise<ssize_t> | Async read via io::read |
write(buf, len) | Promise<ssize_t> | Async write via io::write |
try_read(buf, len) | ssize_t | Sync non-blocking read, -1/EAGAIN if none |
try_write(buf, len) | ssize_t | Sync non-blocking write, -1/EAGAIN if full |
peek(buf, len) | Promise<ssize_t> | Read without consuming (MSG_PEEK) |
readable() | Promise<void> | Resolve when fd is readable |
writable() | Promise<void> | Resolve when fd is writable |
take_error() | int | Get & clear SO_ERROR, 0 if none |
nodelay() / set_nodelay(bool) | io::Result<bool> | TCP_NODELAY get/set |
ttl() / set_ttl(uint32_t) | io::Result<uint32_t> / io::Result<bool> | IP_TTL get/set |
linger() / set_linger(int32_t) | io::Result<int32_t> / io::Result<bool> | SO_LINGER get/set |
peer_addr() | Option<SocketAddr> | Peer's address |
local_addr() | Option<SocketAddr> | Local address |
close() | void | Close + deregister |
is_open() | bool | Connection is active |
How it works
TcpStream wraps xTcpConn + io::AsyncFd. The fd is extracted from xTcpConnSocket() and registered with the event loop. read()/write() delegate to io::read()/io::write() (fast-path ::read/::write + EAGAIN readiness wait).
connect() uses async() + new TcpConnectAdapter — the adapter self-deletes in its callback because xTcpConnect has no cancel API. If the Promise is dropped before connect completes, the adapter stays alive until the callback fires (bounded by libx's 10s connect timeout) and resolves to a no-op via ArcWeak.
Connect with TLS
Pass Option<const TlsContext&> to enable TLS. The handshake is transparent:
xpp::net::TlsContext tls(xpp::net::TlsConfig::client());
auto conn = xpp::net::TcpStream::connect("example.com:443", tls).await();
// conn.read() / conn.write() transparently encrypt/decrypt
TcpListener
API Reference
| Method | Returns | Description |
|---|---|---|
bind(SocketAddr) | Promise<io::Result<TcpListener>> | Async bind (sync impl, resolves immediately) |
bind("host:port") | Promise<io::Result<TcpListener>> | Async bind with DNS for hostnames |
accept() | Promise<pair<TcpStream, SocketAddr>> | Next incoming connection + peer address |
local_addr() | Option<SocketAddr> | Bound address |
close() | void | Stop listening |
is_open() | bool | Listener is active |
How it works
TcpListener wraps xTcpListener. The underlying state (listener handle + pending resolver) lives in a shared_ptr<Impl> so the libx callback's void* arg stays stable across moves.
accept() returns both the stream and the peer address in a pair<TcpStream, SocketAddr> — the libx callback provides the address for free, no extra syscall.
local_addr() uses xTcpListenerSocket (new libx API) to get the listener's fd, then getsockname() to retrieve the bound address.
Usage Examples
TCP Echo Server
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto listener_r = xpp::net::TcpListener::bind("0.0.0.0:8080").await();
ASSERT_TRUE(listener_r.is_ok());
auto listener = std::move(listener_r).unwrap();
auto session = [](xpp::net::TcpStream conn) {
auto buf = std::make_shared<std::vector<char>>(1024);
auto conn_ptr = std::make_shared<xpp::net::TcpStream>(std::move(conn));
return conn_ptr->recv(buf->data(), buf->size())
.then([conn_ptr, buf](ssize_t n) mutable {
return conn_ptr->send(buf->data(), static_cast<size_t>(n));
})
.then([conn_ptr](ssize_t) mutable {});
};
session(listener.accept().await().first).await();
TCP Client with TLS
xpp::net::TlsContext tls(xpp::net::TlsConfig::client());
auto conn = xpp::net::TcpStream::connect("example.com:443", tls).await();
conn.write("GET / HTTP/1.0\r\n\r\n", 18).await();
char buf[4096];
ssize_t n = conn.read(buf, sizeof(buf)).await();
Coroutine Examples
Promise<T> is a coroutine return type — functions returning Promise<T> can use co_await / co_return. Requires C++20.
Echo server (coroutine)
xpp::Promise<void> session(xpp::net::TcpStream conn) {
auto buf = std::make_shared<std::vector<char>>(1024);
ssize_t n = co_await conn.read(buf->data(), buf->size());
co_await conn.write(buf->data(), static_cast<size_t>(n));
}
xpp::Promise<void> server(xpp::net::TcpListener listener) {
while (listener.is_open()) {
auto [conn, addr] = co_await listener.accept();
co_await session(std::move(conn));
}
}
Client with TLS (coroutine)
xpp::Promise<void> fetch() {
xpp::net::TlsContext tls(xpp::net::TlsConfig::client());
auto conn = co_await xpp::net::TcpStream::connect("example.com:443", tls);
co_await conn.write("GET / HTTP/1.0\r\n\r\n", 18);
char buf[4096];
ssize_t n = co_await conn.read(buf, sizeof(buf));
printf("%.*s\n", (int)n, buf);
}
Concurrent server + client
xpp::Promise<void> echo_server(xpp::net::TcpListener listener) {
auto [conn, addr] = co_await listener.accept();
char buf[64];
ssize_t n = co_await conn.read(buf, sizeof(buf));
co_await conn.write(buf, n);
}
xpp::Promise<void> echo_client(uint16_t port) {
auto conn = co_await xpp::net::TcpStream::connect(("127.0.0.1:" + std::to_string(port)).c_str());
co_await conn.write("hello", 5);
char buf[64];
ssize_t n = co_await conn.read(buf, sizeof(buf));
// n == 5, buf == "hello"
}
// Drive both concurrently:
xpp::all(echo_server(std::move(listener)), echo_client(port)).await();
Implementation Notes
-
TcpConnectAdapter self-deletes —
xTcpConnecthas no cancel API. The adapter is heap-allocated (new) and self-deletes in its callback. If the Promise is dropped first, the callback still fires (bounded by 10s timeout) and resolves to a no-op viaArcWeak. -
TcpListener uses shared_ptr
— xTcpListenerCreatestores avoid* argpointer. TheImplstruct is heap-allocated and stable, so moves don't dangle the callback arg. -
Buffer lifetime —
bufpointers passed torecv/sendmust remain valid until the returned Promise resolves.
UDP
Introduction
xpp::net::UdpSocket provides Promise-based async UDP. libx has no UDP API, so UdpSocket is built directly on ::socket(), ::bind(), and io::AsyncFd.
Example — .await()
#include <xpp/net/udp.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto server = xpp::net::UdpSocket::bind("127.0.0.1:9090").await().unwrap();
char buf[64];
auto result = server.recv_from(buf, sizeof(buf)).await();
// result.first == bytes read
// result.second == peer SocketAddr
Example — co_await (C++20)
xpp::Promise<void> recv_demo() {
auto server = (co_await xpp::net::UdpSocket::bind("127.0.0.1:9090")).unwrap();
char buf[64];
auto result = co_await server.recv_from(buf, sizeof(buf));
// result.first == bytes read
// result.second == peer SocketAddr
}
API Reference
| Method | Returns | Description |
|---|---|---|
bind(SocketAddr) | Promise<io::Result<UdpSocket>> | Async bind (sync impl, resolves immediately) |
bind("host:port") | Promise<io::Result<UdpSocket>> | Async bind with DNS for hostnames |
connect(addr) | xErrno | Connect to peer (enables recv/send) |
recv(buf, len) | Promise<ssize_t> | Connected-mode recv |
send(buf, len) | Promise<ssize_t> | Connected-mode send |
recv_from(buf, len) | Promise<pair<ssize_t, SocketAddr>> | Receive datagram + peer |
send_to(buf, len, target) | Promise<ssize_t> | Send datagram to target |
try_recv(buf, len) | ssize_t | Sync non-blocking recv |
try_send(buf, len) | ssize_t | Sync non-blocking send |
try_recv_from(buf, len) | pair<ssize_t, Option<Addr>> | Sync non-blocking recvfrom |
try_send_to(buf, len, target) | ssize_t | Sync non-blocking sendto |
peek(buf, len) | Promise<ssize_t> | Read without consuming (MSG_PEEK) |
peek_from(buf, len) | Promise<pair<ssize_t, Addr>> | Peek + sender address |
readable() | Promise<void> | Wait for readability |
writable() | Promise<void> | Wait for writability |
take_error() | int | Get & clear SO_ERROR |
broadcast() | io::Result<bool> | Get SO_BROADCAST |
set_broadcast(bool) | io::Result<bool> | Set SO_BROADCAST |
ttl() | io::Result<uint32_t> | Get IP_TTL |
set_ttl(uint32_t) | io::Result<bool> | Set IP_TTL |
peer_addr() | Option<SocketAddr> | Connected peer address |
local_addr() | Option<SocketAddr> | Bound address |
close() | void | Close + deregister |
is_open() | bool | Socket is active |
How it works
UdpSocket creates a non-blocking UDP socket via ::socket(AF_INET, SOCK_DGRAM, 0), sets O_NONBLOCK + FD_CLOEXEC, binds, and registers with the event loop via AsyncFd.
recv_from() tries ::recvfrom immediately (fast path). On EAGAIN, it waits for readable() and retries. Returns Promise<std::pair<ssize_t, SocketAddr>> — bytes read + peer address.
send_to() tries ::sendto immediately. On EAGAIN, it waits for writable() and retries.
Usage Examples
UDP Echo — .await()
auto server = xpp::net::UdpSocket::bind("127.0.0.1:9090").await().unwrap();
auto client = xpp::net::UdpSocket::bind("127.0.0.1:0").await().unwrap();
auto target = server.local_addr().unwrap();
client.send_to("ping", 4, target).await();
char buf[64];
auto result = server.recv_from(buf, sizeof(buf)).await();
// result.first == 4, result.second is client's address
UDP Echo — co_await (C++20)
auto server = (co_await xpp::net::UdpSocket::bind("127.0.0.1:9090")).unwrap();
auto client = (co_await xpp::net::UdpSocket::bind("127.0.0.1:0")).unwrap();
auto target = server.local_addr().unwrap();
co_await client.send_to("ping", 4, target);
char buf[64];
auto result = co_await server.recv_from(buf, sizeof(buf));
Implementation Notes
- Built from scratch — libx has no UDP API.
UdpSocketuses::socket()+::bind()+AsyncFddirectly. - Buffer lifetime —
bufpointers passed torecv_from/send_tomust remain valid until the returned Promise resolves. - Bind with DNS —
bind("host:port")triesSocketAddr::parsefirst (literal IP). If that fails, it splits on the last:and callslookup_host()to resolve the hostname.
DNS
Introduction
xpp::net::lookup_host() provides async hostname resolution, wrapping libx's xDnsResolve. Returns a vector of SocketAddr.
Example — .await()
#include <xpp/net/dns.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto addrs = xpp::net::lookup_host("example.com").await();
for (const auto &addr : addrs) {
printf("%s\n", addr.to_string().c_str());
}
Example — co_await (C++20)
xpp::Promise<void> resolve_print(const char *hostname) {
auto addrs = co_await xpp::net::lookup_host(hostname);
for (const auto &addr : addrs) {
printf("%s\n", addr.to_string().c_str());
}
}
API Reference
| Function | Returns | Description |
|---|---|---|
lookup_host(hostname) | Promise<vector<SocketAddr>> | Async DNS resolution |
Resolves to an empty vector on failure (hostname not found, DNS error, etc.).
How it works
lookup_host() uses adapt<vector<SocketAddr>, LookupHostAdapter>(). The adapter calls xDnsResolve in its constructor and xDnsCancel in its destructor. Dropping the Promise mid-query cancels the query safely.
Usage Examples
Resolve and connect — .await()
auto addrs = xpp::net::lookup_host("example.com").await();
if (!addrs.empty()) {
auto conn = xpp::net::TcpStream::connect(addrs[0]).await();
}
Resolve and connect — co_await (C++20)
xpp::Promise<void> connect_to(const char *hostname) {
auto addrs = co_await xpp::net::lookup_host(hostname);
if (addrs.empty()) { printf("host not found\n"); co_return; }
auto conn = co_await xpp::net::TcpStream::connect(addrs[0]);
}
Concurrent resolution — .await() with .then()
xpp::net::lookup_host("example.com").then([](std::vector<xpp::net::SocketAddr> addrs) {
if (addrs.empty()) return xpp::resolve(xpp::net::TcpStream());
return xpp::net::TcpStream::connect(addrs[0]);
}).await();
Concurrent multi-host resolution — co_await (C++20)
xpp::Promise<void> resolve_both() {
auto a = xpp::net::lookup_host("example.com");
auto b = xpp::net::lookup_host("example.org");
auto pair = co_await xpp::all(std::move(a), std::move(b));
printf("example.com: %zu addrs, example.org: %zu addrs\n",
pair.first.size(), pair.second.size());
}
Implementation Notes
- Empty vector on error — libx's DNS callback provides
xDnsResultwith an error field. On error, the adapter resolves with an empty vector (not a rejection). Callers should checkaddrs.empty().
URL
Introduction
xpp::net::Url is an RAII wrapper around libx's xUrl. Parsing returns Result<Url, UrlParseError> — explicit error handling, no exceptions.
#include <xpp/net/url.h>
auto r = xpp::net::Url::parse("https://example.com:8080/path?q=1");
if (r.is_ok()) {
auto u = std::move(r).unwrap();
u.scheme(); // "https"
u.host(); // "example.com"
u.port_num(); // 8080
u.path(); // "/path"
}
API Reference
Url
| Method | Returns | Description |
|---|---|---|
parse(raw) | Result<Url, UrlParseError> | Parse a URL string |
parse(std::string) | Result<Url, UrlParseError> | Parse from std::string |
scheme() | std::string | e.g. "https" |
host() | std::string | e.g. "example.com" |
port_num() | uint16_t | Explicit or scheme default (http=80, https=443) |
path() | std::string | e.g. "/api" |
query() | std::string | e.g. "q=1" |
fragment() | std::string | e.g. "section1" |
userinfo() | std::string | e.g. "user:pass" |
raw() | const xUrl& | Underlying libx handle |
UrlParseError
| Variant | Meaning |
|---|---|
Empty | Input was NULL or empty |
InvalidFormat | Not a valid URL (missing scheme, host, or malformed) |
How it works
Url::parse() calls xUrlParse which makes an internal copy of the input string. All accessor fields point into this copy. ~Url() calls xUrlFree to release it.
The error type is a dedicated enum (UrlParseError) rather than the generic xErrno — matching the pattern of AddrParseError in addr.h.
Usage Examples
Parse and inspect
auto r = xpp::net::Url::parse("http://localhost:3000/api");
if (r.is_ok()) {
auto u = std::move(r).unwrap();
EXPECT_EQ(u.scheme(), "http");
EXPECT_EQ(u.host(), "localhost");
EXPECT_EQ(u.port_num(), 3000);
EXPECT_EQ(u.path(), "/api");
}
Default port
port_num() returns the explicit port if present, otherwise the scheme default:
xpp::net::Url::parse("http://localhost/api").unwrap().port_num(); // 80
xpp::net::Url::parse("https://localhost/api").unwrap().port_num(); // 443
Error handling
auto r = xpp::net::Url::parse("not a url");
if (r.is_err()) {
auto e = r.unwrap_err(); // UrlParseError::InvalidFormat
printf("parse failed: %s\n", xpp::net::url_error_message(e));
}
TLS
Introduction
xpp::net::TlsConfig and TlsContext provide RAII TLS configuration. Pass a TlsContext to TcpStream::connect() to enable TLS — the handshake is transparent.
Example — .await()
#include <xpp/net/tls.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
xpp::net::TlsContext ctx(xpp::net::TlsConfig::client());
auto conn = xpp::net::TcpStream::connect("example.com:443", ctx).await();
Example — co_await (C++20)
xpp::net::TlsContext ctx(xpp::net::TlsConfig::client());
auto conn = co_await xpp::net::TcpStream::connect("example.com:443", ctx);
API Reference
TlsConfig
| Method | Returns | Description |
|---|---|---|
client() | TlsConfig | Client defaults (system CA, verify on) |
client_insecure() | TlsConfig | Client that skips peer verification |
server(cert, key) | TlsConfig | Server config with cert + key paths |
server(cert, key, ca) | TlsConfig | Server config with CA (for mTLS) |
with_cert(path) | TlsConfig& | Builder: set cert path |
with_key(path) | TlsConfig& | Builder: set key path |
with_ca(path) | TlsConfig& | Builder: set CA path |
with_key_password(pw) | TlsConfig& | Builder: set key password |
with_alpn(protocols) | TlsConfig& | Builder: set ALPN list |
with_skip_verify(bool) | TlsConfig& | Builder: toggle verification |
raw() | const xTlsConf* | Underlying libx config |
Each with_* builder has two overloads, selected by ref-qualifier:
with_*(...) &→ returnsTlsConfig&, modifies in-place (lvalue chain)with_*(...) &&→ returnsTlsConfig&&, enables move (rvalue chain)
// Rvalue chain: temporary factory → && overloads → move at end
auto conf = TlsConfig::client().with_cert(...).with_key(...);
// Lvalue chain: named variable → & overloads → modify in-place
TlsConfig conf = TlsConfig::client();
conf.with_ca("/custom/ca.pem").with_alpn({"h2", "http/1.1"});
TlsContext
| Method | Returns | Description |
|---|---|---|
TlsContext(conf) | TlsContext | Create context from TlsConfig |
TlsContext(xTlsConf*) | TlsContext | Create from raw libx config |
reload(conf) | int | Hot-reload certificates (0 = success) |
raw() | xTlsCtx | Underlying libx context |
is_valid() | bool | Construction succeeded |
operator bool() | bool | Same as is_valid() |
How it works
TlsConfig is a builder that owns the string storage (cert path, key path, CA path, key password, ALPN protocols). It wraps xTlsConf (a POD of pointers).
TlsContext calls xTlsCtxCreate in its constructor and xTlsCtxDestroy in its destructor. The mode (client or server) is determined automatically by libx: if both cert and key are set, server mode; otherwise client mode.
When passed to TcpStream::connect(), the xTlsCtx handle is set in xTcpConnectConf::tls_ctx. libx's xTcpConnect does the TLS handshake transparently.
Usage Examples
Client with system CA — .await()
xpp::net::TlsContext tls(xpp::net::TlsConfig::client());
auto conn = xpp::net::TcpStream::connect("example.com:443", tls).await();
conn.write("GET / HTTP/1.0\r\n\r\n", 18).await();
Client with system CA — co_await (C++20)
xpp::Promise<void> https_fetch() {
xpp::net::TlsContext tls(xpp::net::TlsConfig::client());
auto conn = co_await xpp::net::TcpStream::connect("example.com:443", tls);
co_await conn.write("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n", 40);
char buf[4096];
ssize_t n = co_await conn.read(buf, sizeof(buf));
printf("%.*s\n", (int)n, buf);
}
Server with certificate
xpp::net::TlsContext tls(xpp::net::TlsConfig::server("cert.pem", "key.pem"));
Builder pattern
xpp::net::TlsConfig conf = xpp::net::TlsConfig::client()
.with_ca("/custom/ca.pem")
.with_alpn({"h2", "http/1.1"});
xpp::net::TlsContext ctx(conf);
mTLS (mutual TLS)
xpp::net::TlsContext server_tls(
xpp::net::TlsConfig::server("server.pem", "server.key", "ca.pem"));
xpp::net::TlsConfig client_conf = xpp::net::TlsConfig::client()
.with_cert("client.pem").with_key("client.key");
xpp::net::TlsContext client_tls(client_conf);
Connect with error handling — .await()
xpp::net::TlsContext tls(xpp::net::TlsConfig::client());
auto conn = xpp::net::TcpStream::connect("example.com:443", tls).await();
if (!conn.is_open()) { /* handle failure */ }
conn.write("hello", 5).await();
Connect with error handling — co_await (C++20)
xpp::Promise<void> connect_or_fallback() {
xpp::net::TlsContext tls(xpp::net::TlsConfig::client());
auto conn = co_await xpp::net::TcpStream::connect("example.com:443", tls);
if (!conn.is_open()) { co_return; }
co_await conn.write("hello", 5);
}
xpp::http
HTTP client and server for libxpp — Client / Server, async-first, wrapping libx's xHttpClient / xHttpServer C APIs.
Introduction
xpp::http provides a hyper/reqwest-shaped HTTP stack: a Client with get/post/put/del/patch/head conveniences, and a Server with template-injected path parameters and async handlers. Everything is Promise-based — no blocking I/O in the async path.
Header-only, C++11-compatible, with C++20 coroutine sugar (co_await / co_return) when XPP_HAS_COROUTINES is defined. Errors flow through Result<T, http::Error> — no exceptions.
Client at a Glance
C++20 — coroutines:
#include <xpp/http/client.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto client = xpp::http::Client::builder().timeout(5000).build().unwrap();
xpp::Promise<void> fetch() {
auto r = co_await client.get("https://example.com/api");
if (r.is_err()) { /* transport failure or 4xx/5xx */ co_return; }
auto resp = std::move(r).unwrap();
auto body = co_await resp.bytes();
// ...
co_return;
}
// Drive it — either parks the caller (running the loop) or fire-and-forget:
fetch(client).await();
xpp::spawn(fetch(client));
C++11 — .await() (parks the caller, driving the event loop):
#include <xpp/http/client.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto client = xpp::http::Client::builder().timeout(5000).build().unwrap();
auto r = client.get("https://example.com/api").await();
if (r.is_err()) { /* transport failure or 4xx/5xx */ }
auto resp = r.unwrap();
auto body = resp.bytes().await().unwrap();
The response Body is streamed through an mpsc channel with backpressure — read it via bytes(), text(), or body().read(). 4xx/5xx statuses surface as Err(Error) (a Protocol error carrying the status code).
Server at a Glance
#include <xpp/http/server.h>
xpp::EventLoop loop;
xpp::WaitScope scope(loop);
auto server = xpp::http::Server::builder()
.route("GET /users/:id", [](xpp::http::Request req, xpp::String id) {
return xpp::http::Response::ok(id);
})
.bind("127.0.0.1", 8080) // port 0 = kernel-assigned
.build()
.unwrap();
auto running = server.serve(); // listens synchronously; resolves on stop()
// ... on another fiber/loop iteration:
server.stop();
Async handlers come in two flavors — the same POST /echo route:
C++20 — coroutine:
.route("POST /echo", [](xpp::http::Request req) -> xpp::Promise<xpp::http::Result<xpp::http::Response>> {
auto body = co_await req.into_body().bytes();
return xpp::http::Response::ok(body.unwrap());
})
C++11 — .then() chain:
.route("POST /echo", [](xpp::http::Request req) -> xpp::Promise<xpp::http::Result<xpp::http::Response>> {
return req.into_body().bytes().then([](xpp::http::Result<xpp::Bytes> b) {
return xpp::http::Response::ok(b.unwrap());
});
})
Handlers run on the event loop via xpp::spawn (waker-driven, no per-request fiber). Path parameters (:name) are injected as handler arguments in pattern order. A channel-backed response body is streamed out (xHttpCtxWrite / close-delimited in HTTP/1).
Body Model
xpp::http::Body has three kinds:
| Kind | Producer | Consumer |
|---|---|---|
Empty | Body::empty() | EOF immediately |
Once | Body::from(bytes / string / Vec) | one-shot bytes |
Channel | Body::from_channel(mpsc::Receiver<Bytes>) | streamed, waker-driven |
See Body for reading, backpressure, and streaming responses.
Error Handling
- Transport failures (connect/DNS/timeout) →
Err(Error{Kind::Connect|Dns|Timeout|…}) - 4xx/5xx responses →
Err(Error{Kind::Protocol, status}) - Server handler returning
Err→ 500 to the client - Unmatched route → 404
- Request body overflow (channel full) → 413
Related
Client
Async HTTP client wrapping libx's xHttpClient (libcurl). Promise-based, hyper/reqwest-shaped.
Construction
auto client = xpp::http::Client::builder()
.timeout(5000) // total request timeout, ms (default: no timeout?)
.connect_timeout(3000) // TCP/TLS connect timeout
.read_timeout(5000) // read timeout
.redirect(3) // follow up to 3 redirects
.user_agent("xpp/1.0")
.proxy("http://proxy:8080") // optional
.header("Accept-Encoding", "identity")
.build()
.unwrap();
Builder options: timeout, connect_timeout, read_timeout, redirect, max_redirects, user_agent, proxy, no_proxy, header (default request headers).
Making Requests
C++20 — coroutines:
// Inside a coroutine — convenience methods take URL as String,
// const char*, or std::string_view:
xpp::Promise<void> run(xpp::http::Client &client) {
auto r = co_await client.get("https://example.com/a"); // Result<Response>
auto p = co_await client.post("https://example.com/upload", "payload");
auto d = co_await client.del("https://example.com/items/7");
auto h = co_await client.head("https://example.com");
co_return;
}
// fetch(client).await(); — or spawn it:
// xpp::spawn(fetch(client));
C++11 — .await() (parks the caller, driving the event loop):
auto r = client.get("https://example.com/a").await(); // Promise<Result<Response>>
auto p = client.post("https://example.com/upload", "payload").await();
auto d = client.del("https://example.com/items/7").await();
auto h = client.head("https://example.com").await();
Full control via Request (either standard):
auto req = xpp::http::Request::builder()
.method(xpp::http::Method::Post)
.url("https://example.com/api")
.header("Content-Type", "application/json")
.body(R"({"k":"v"})")
.build()
.unwrap();
auto r = client.send(std::move(req)).await(); // or: co_await client.send(std::move(req));
Each call returns Promise<Result<Response, http::Error>>:
Ok(Response)— headers arrived. The body is streamed via an mpsc channel with backpressure; read it withbytes()/text()/body().read(). A transfer failure mid-body surfaces as a read error, not a truncated EOF.Err(http::Error)— transport failure before headers (connect/DNS/timeout), or a 4xx/5xx status (aProtocolerror whosestatus()carries the code).
Reading the Response
auto resp = r.unwrap();
uint16_t code = resp.status_code();
auto headers = resp.headers(); // HeaderMap (case-insensitive keys)
auto final = resp.final_url(); // Some(url) after redirects
C++20 — coroutines:
// Whole body at once:
auto bytes = co_await resp.bytes(); // Result<Bytes>
auto text = co_await resp.text(); // Result<String> (UTF-8)
// Or stream it:
auto body = resp.into_body();
char buf[4096];
ssize_t n;
while ((n = co_await body.read(buf, sizeof(buf))) > 0) { /* process */ }
C++11 — .await():
// Whole body at once:
auto bytes = resp.bytes().await().unwrap(); // Promise<Result<Bytes>>
auto text = resp.text().await().unwrap(); // Promise<Result<String>> (UTF-8)
// Or stream it:
auto body = resp.into_body();
char buf[4096];
ssize_t n;
while ((n = body.read(buf, sizeof(buf)).await()) > 0) { /* process */ }
Response is move-only (hyper style). bytes()/text() consume the body; read() follows the AsyncReader concept so io::read_all / io::copy work directly.
Request Bodies
// Once bodies — bytes / Vec<uint8_t> / String / const char*:
.builder().body("text payload")
.builder().body(xpp::Bytes::copy(data, len))
Channel (streaming) request bodies are not yet supported for upload — into_once_bytes() returns empty for a channel body. This is a documented limitation.
Error Types
http::Error carries a Kind (Connect, Dns, Timeout, Protocol, Body, Io, …) and a message. Protocol errors additionally carry the HTTP status (error.status()). See error.h for the full list.
Related
- Body — reading and streaming
- Server — the other half
- libx HTTP Client (C API)
Server
Async HTTP server wrapping libx's xHttpServer (HTTP/1.1 + HTTP/2). Handlers run on the event loop via xpp::spawn — waker-driven, no per-request fiber stack.
Construction
auto server = xpp::http::Server::builder()
.route("GET /users/:id", handler)
.route("/health", handler) // any method
.idle_timeout(30000) // ms, 0 = no timeout (default 60000)
.bind("127.0.0.1", 8080) // port 0 = kernel-assigned
.build()
.unwrap();
auto running = server.serve(); // listens synchronously
uint16_t port = server.port(); // actual port (bind(0))
// ...
server.stop(); // resolve serve() → connections drained
running.await();
serve() returns Promise<Result<void>> that resolves when stop() is called. Listening is synchronous, so port() is immediately valid.
Routes & Path Parameters
Pattern is "METHOD /path" (or /path for any method). :name segments become handler arguments, injected in pattern order, as xpp::String:
.route("GET /users/:id/posts/:post", [](Request req, String id, String post) {
return Response::ok(id + " / " + post); // "42 / 7" for /users/42/posts/7
})
The handler parameter count is checked at registration (XPP_ASSERT).
Handler Signatures
A handler takes Request by value (hyper style — move-in) plus the injected parameters, and returns one of:
| Return type | Meaning |
|---|---|
Response | synchronous response |
Result<Response> | synchronous, fallible (Err → 500) |
Promise<Result<Response>> | async handler (.then() chain, or a co_await coroutine) |
// Sync (either standard):
.route("GET /ok", [](Request req) { return Response::ok("fine"); })
C++20 — coroutine:
// Async — reads the request body, then responds:
.route("POST /echo", [](Request req) -> Promise<Result<Response>> {
auto body = co_await req.into_body().bytes();
return Response::ok(body.unwrap());
})
C++11 — .then() chain:
// Async — same route, promise composition instead of a coroutine:
.route("POST /echo", [](Request req) -> Promise<Result<Response>> {
return req.into_body().bytes().then([](Result<Bytes> b) {
return Response::ok(b.unwrap());
});
})
// Timed work chains the same way:
.route("GET /slow", [](Request req) -> Promise<Result<Response>> {
return xpp::after(50).then([]() { return Response::ok("done"); });
})
Handler errors: returning Err answers 500. Unmatched routes answer 404 (libx). A request body that overflows the channel answers 413.
Request Body
The request Body is a channel fed by libx's on_data callback — streamed with backpressure, no full-buffering:
C++20 — coroutine:
.route("POST /sum", [](Request req) -> Promise<Result<Response>> {
auto bytes = co_await req.into_body().bytes(); // or .text(), or read() in a loop
auto n = bytes_to_sum(bytes);
return Response::ok(std::to_string(n));
})
C++11 — .then() chain:
.route("POST /sum", [](Request req) -> Promise<Result<Response>> {
return req.into_body().bytes().then([](Result<Bytes> b) {
return Response::ok(std::to_string(bytes_to_sum(b.unwrap())));
});
})
The channel has a fixed capacity (256 chunks); when full, libx pauses the connection and resumes once the consumer drains.
Streaming Responses
Return a channel-backed body and the server streams it out via xHttpCtxWrite (close-delimited in HTTP/1; nghttp2 streams in H2):
C++20 — coroutine producer (pass the lambda directly to spawn — see the lifetime note below):
#include <xpp/http/body.h>
#include <xpp/sync/mpsc.h>
.route("GET /countdown", [](Request req) -> Result<Response> {
auto [tx, rx] = xpp::sync::mpsc::channel<xpp::Bytes>(4);
xpp::spawn([tx]() mutable -> Promise<void> {
for (int i = 3; i > 0; --i) co_await tx.send(xpp::Bytes::copy(std::to_string(i).c_str(), 1));
tx.close();
co_return;
});
auto body = Body::from_channel(std::move(rx));
return Response::ok(std::move(body));
})
C++11 — recursive .then() producer:
// A struct whose operator() sends one chunk and chains itself for the
// next — the .then()-era equivalent of a coroutine loop.
struct Countdown {
xpp::sync::mpsc::Sender<xpp::Bytes> tx;
int i = 3;
xpp::Promise<void> operator()() {
if (i == 0) {
tx.close(); // EOF for the reader
return xpp::resolve();
}
return tx.send(xpp::Bytes::copy(std::to_string(i).c_str(), 1)).then([this]() {
--i;
return (*this)();
});
}
};
.route("GET /countdown", [](Request req) -> Result<Response> {
auto [tx, rx] = xpp::sync::mpsc::channel<xpp::Bytes>(4);
xpp::spawn(Countdown{tx}); // defer node keeps a heap copy alive
// for the whole chain
auto body = Body::from_channel(std::move(rx));
return Response::ok(std::move(body));
})
The stream ends when the channel closes. The write loop drops remaining chunks if the server is destroyed mid-stream or the connection dies.
Coroutine-lambda lifetime: a lambda coroutine's frame stores the closure pointer, not a copy. Pass the lambda directly to
xpp::spawn(...)(the defer node keeps a heap copy for the chain's lifetime), use a named coroutine function, or keep the closure alive where it is declared.auto make = [&]{...}; spawn(make());on a dying stack frame crashes. Seeissues/coro-nested-spawn-capture-lambda-crash.md.
Router & Middleware (tower/axum-aligned)
Routing, path parameters, 404/405, and middleware live in the composable
Router (<xpp/http/router.h>). A Router is itself a handler — hand one to
.router(...), nest it under a prefix, or call it directly in tests:
Router r;
r.route("GET /users/:id", [](Request req, String id) { return Response::ok(id); })
.route("/health", [](Request) { return Response::ok("fine"); });
Router api;
api.route("/users/:id", handler);
r.nest("/api", std::move(api)); // strips "/api" — sub-router is prefix-unaware
auto server = Server::builder()
.router(std::move(r))
.bind("127.0.0.1", 8080)
.build()
.unwrap();
ServerBuilder::route() registers into the builder's internal Router;
layer() adds middleware to it.
Middleware
A middleware is Handler -> Handler where the unified Handler is
Request -> Promise<Result<Response>> (hyper's Service::call shape).
Registration order follows tower's ServiceBuilder: the first layer
registered is the outermost.
C++20 — coroutine layer:
auto logging = [](Router::HandlerFn next) -> Router::HandlerFn {
return [next](Request req) -> Promise<Result<Response>> {
XLOG_INFO("-> {} {}", to_string(req.method()), req.url());
auto r = co_await next(std::move(req));
co_return r;
};
};
C++11 — .then() layer:
auto tag = [](Router::HandlerFn next) -> Router::HandlerFn {
return [next](Request req) -> Promise<Result<Response>> {
return next(std::move(req)).then([](Result<Response> r) {
return xpp::resolve(std::move(r));
});
};
};
Layers run after matching — path parameters are readable via
req.param("id") — and can short-circuit (return without calling next).
nest() freezes the sub-router and bakes its layers into its routes
(sub layers inner, outer router's layers outer).
Fallback & status answers
- No route matches the path → the
fallback(h)handler, or 404 by default - Path matches a pattern but the method doesn't → 405
- A Router with no routes at all still answers 404
Standalone unit-testing: call the Router directly — no sockets:
Router r;
r.route("GET /a", [](Request) { return Response::ok("a"); });
auto resp = r(Request::builder().method(Method::Get).url("/a").body().unwrap()).await();
Concurrency
Requests on the same route run concurrently — per-request state is stored via xHttpCtxSetUser (user pointer delivered to on_data/on_done), so streaming bodies of simultaneous requests stay separate. Test ConcurrentBodiesStaySeparate covers this.
Lifecycle & Safety
Serveris move-only; the destructor tears down the C server.- In-flight handlers: a handler may complete after the
Serveris destroyed. The spawn chain captures aServerLifetimeArc and drops the response write instead of touching the freedctx(testDestroyWithInflightHandlerDoesNotCrash). serve()blocks nothing — run the loop as usual;stop()resolves it.
Related
- Body — request/response body, channels, streaming
- Client
- libx HTTP Server (C API)
Body
xpp::http::Body is the request/response body value — move-only, hyper-style. It satisfies the AsyncReader concept, so io::read_all / io::copy work on it directly.
Kinds
A Body is one of three kinds:
| Kind | Construction | Read behavior |
|---|---|---|
Empty | Body::empty() | immediate EOF (read() returns 0) |
Once | Body::from(bytes / Vec<uint8_t> / String / const char*) | one-shot bytes |
Channel | Body::from_channel(mpsc::Receiver<Bytes>) | streamed — read() suspends when the channel is empty, resumes on wake |
Body a = Body::empty();
Body b = Body::from(xpp::Bytes::copy("hi", 2));
Body c = Body::from(std::string("text"));
auto [tx, rx] = xpp::sync::mpsc::channel<xpp::Bytes>(64);
Body s = Body::from_channel(std::move(rx));
tx.send(xpp::Bytes::copy("chunk", 5)); // feeds the stream
tx.close(); // EOF for the reader
Reading
C++20 — coroutines:
// Whole body:
auto bytes = co_await body.bytes(); // Result<Bytes>
auto text = co_await body.text(); // Result<String> (UTF-8)
// Streaming (AsyncReader):
char buf[4096];
ssize_t n;
while ((n = co_await body.read(buf, sizeof(buf))) > 0) { /* process */ }
// n == 0 at EOF
C++11 — .await():
// Whole body:
auto bytes = body.bytes().await().unwrap(); // Promise<Result<Bytes>>
auto text = body.text().await().unwrap(); // Promise<Result<String>> (UTF-8)
// Streaming (AsyncReader):
char buf[4096];
ssize_t n;
while ((n = body.read(buf, sizeof(buf)).await()) > 0) { /* process */ }
// n == 0 at EOF
For a channel body, read() returns a Pending promise when the channel is empty; the reading coroutine/fiber suspends until a chunk arrives or the sender closes. This is waker-driven — nothing busy-polls.
Lifetime:
read()does not extend the Body's lifetime. Keep theBodyin a named variable while awaiting (unlikebytes()/text(), which move it into anArcinternally).
Observers
bool empty = body.is_empty(); // Empty kind or exhausted
bool channel = body.is_channel(); // backed by a stream
xpp::Bytes once = body.into_once_bytes(); // Once/Empty only (channel → empty)
Streaming Request Bodies
The server-side request body is a channel fed by libx's on_data callback — req.into_body() gives you a channel Body that streams with backpressure:
C++20 — coroutine:
.route("POST /echo", [](Request req) -> Promise<Result<Response>> {
auto body = co_await req.into_body().bytes(); // full body, streamed
return Response::ok(body);
})
C++11 — .then() chain:
.route("POST /echo", [](Request req) -> Promise<Result<Response>> {
return req.into_body().bytes().then([](Result<Bytes> b) {
return Response::ok(b.unwrap());
});
})
The server channel has fixed capacity; when full, libx pauses the connection and resumes once you drain it (natural backpressure).
Client-side request upload only supports Once bodies today — into_once_bytes() returns empty for a channel body (documented limitation).
Streaming Response Bodies
Return a channel Body from a handler and the server streams it:
C++20 — coroutine producer (pass the lambda directly to spawn — see the lifetime note below):
.route("GET /stream", [](Request req) -> Result<Response> {
auto [tx, rx] = xpp::sync::mpsc::channel<xpp::Bytes>(4);
xpp::spawn([tx]() mutable -> Promise<void> {
co_await tx.send(xpp::Bytes::copy("part1", 5));
co_await tx.send(xpp::Bytes::copy("-part2", 6));
tx.close();
co_return;
});
return Response::ok(Body::from_channel(std::move(rx)));
})
C++11 — recursive .then() producer:
// A struct whose operator() sends one chunk and chains itself for the
// next — the .then()-era equivalent of a coroutine loop.
struct StreamProducer {
xpp::sync::mpsc::Sender<xpp::Bytes> tx;
int i = 2;
xpp::Promise<void> operator()() {
if (i == 0) {
tx.close(); // EOF for the reader
return xpp::resolve();
}
return tx.send(chunk(i)).then([this]() {
--i;
return (*this)();
});
}
};
.route("GET /stream", [](Request req) -> Result<Response> {
auto [tx, rx] = xpp::sync::mpsc::channel<xpp::Bytes>(4);
xpp::spawn(StreamProducer{tx}); // defer node keeps a heap copy alive
// for the whole chain
return Response::ok(Body::from_channel(std::move(rx)));
})
Each chunk is written via xHttpCtxWrite; closing the channel ends the stream (xHttpCtxEndStream). HTTP/1 streams are close-delimited (Connection: close); H2 uses nghttp2 streams.
Related
- Client — response bodies
- Server — request bodies & streaming responses
- Channels — mpsc
- I/O —
io::read_all,io::copy
LibX
libx is organized into nine libraries, layered from low-level core primitives up to high-level async networking, filesystem I/O, crypto, and DNS.
┌─────────────────────────────────────────────────────────┐
│ Application Layer │
├──────────────────────┬──────────────────┬───────────────┤
│ xhttp │ xp2p │ xdns │
│ HTTP Client/Server │ ICE/STUN/TURN │ DNS Client │
│ WebSocket / SSE │ Peer Connection │ DNS Server │
├──────────────────────┼──────────────────┼───────────────┤
│ xnet │ xlog │ xfs │
│ URL / TCP / TLS │ Async Logging │ Async FS │
├──────────────────────┴──────────────────┴───────────────┤
│ xbuf — Linear / Ring / Block-Chain Buffer │
├──────────────────────┬──────────────────────────────────┤
│ xbase │ xcrypto │
│ Event Loop / Timer │ SHA-1/256 MD5 CRC-32 │
│ Task / Memory / IO │ HMAC / UUID │
└──────────────────────┴──────────────────────────────────┘
Overview
| Library | Description |
|---|---|
| xbase | Core primitives — event loop, timers, tasks, async sockets, memory, lock-free data structures |
| xbuf | Buffer primitives — linear, ring, and block-chain I/O buffers |
| xcrypto | Cryptographic primitives — SHA-1, SHA-256 (OpenSSL / mbedTLS / builtin), MD5, CRC-32, HMAC, UUID (v4/v5/v7) |
| xnet | Networking primitives — URL parser, async DNS resolution, TCP, shared TLS configuration types |
| xlog | Async logging — MPSC queue, timer/pipe flush, log rotation |
| xhttp | Async HTTP client & server — libcurl multi-socket client with SSE streaming, HTTP/1.1 & HTTP/2 async server with TLS, WebSocket server & client |
| xdns | Async DNS client & server — protocol-native resolver over UDP with TTL caching, bitmask queries, authoritative zones, forwarding, and query filtering |
| xp2p | P2P connectivity — ICE agent, STUN/TURN client, SDP codec, NAT traversal |
| xfs | Async filesystem I/O — open, close, read, write, stat, mkdir, rmdir, unlink, rename via thread pool offload with dual async/sync modes |
Dependency Order
Level 0 (no deps) : atomic.h, error.h, time.h
Level 1 (atomic only) : heap.h, mpsc.h
Level 2 (Level 0-1) : memory.h, random.h, log.h, backtrace.h, buf.h, ring.h
Level 3 (Level 0-2) : event.h, io.h, url.h, tls.h
Level 4 (event loop) : timer.h, task.h, socket.h, fs.h, dns.h (xnet), tcp.h, logger.h, client.h, server.h, ws.h
Level 5 (xbase+xnet) : ice_agent.h, stun_msg.h, stun_attr.h, stun_txn.h, turn_client.h, sdp.h, dns.h (xdns)
Level ∞ (standalone) : sha1.h, sha256.h, md5.h, crc32.h, hmac.h, uuid.h (xcrypto — depends only on xbase error codes)
xbase — Event-Driven Async Foundation
Introduction
xbase is the foundational module of libx, providing the core primitives for building event-driven, asynchronous C applications on macOS and Linux. It delivers a cross-platform event loop, monotonic timers, an N:M task model (thread pool), async sockets, reference-counted memory management, lock-free data structures, and essential utilities — all in a minimal, zero-dependency C99 package.
xbase is designed to be the "kernel" that higher-level libx modules (xbuf, xhttp, xlog) build upon. Every I/O-bound or timer-driven feature in libx ultimately relies on xbase's event loop and concurrency primitives.
Design Philosophy
-
Edge-Triggered by Default — The event loop operates in edge-triggered mode across all backends (kqueue, epoll, poll), encouraging callers to drain file descriptors completely. This yields higher throughput and fewer spurious wakeups compared to level-triggered designs.
-
Layered Abstraction — Low-level primitives (atomic, mpsc, heap) are composed into mid-level services (timer, task) which are then integrated into the high-level event loop. Each layer is independently usable.
-
Zero Allocation in the Hot Path — Data structures like the MPSC queue and min-heap are designed to avoid dynamic allocation during normal operation. Memory is pre-allocated or embedded in user structs.
-
Thread-Safety Where It Matters — APIs that are expected to be called cross-thread (e.g.,
xEventWake,xTimerSubmitAfter,xMpscPush) are explicitly designed to be thread-safe. Single-threaded APIs are documented as such. -
vtable-Driven Lifecycle — The memory module uses a virtual table pattern (ctor/dtor/retain/release) to provide reference-counted object management in pure C, inspired by Objective-C's retain/release model.
-
Platform Adaptation at Build Time — Platform-specific code (kqueue vs. epoll, libunwind vs. execinfo) is selected via compile-time macros, keeping runtime overhead at zero.
Architecture
graph TD
subgraph "High-Level Services"
EVENT["event.h<br/>Event Loop"]
TIMER["timer.h<br/>Monotonic Timer"]
TASK["task.h<br/>N:M Task Model"]
SOCKET["socket.h<br/>Async Socket"]
CMD["cmd.h<br/>Command Executor"]
end
subgraph "Infrastructure"
MEMORY["memory.h<br/>Ref-Counted Memory"]
SLAB["slab.h<br/>Slab Object Pool"]
LOG["log.h<br/>Thread-Local Log"]
BACKTRACE["backtrace.h<br/>Stack Backtrace"]
ERROR["error.h<br/>Error Codes"]
TIME["time.h<br/>Time Utilities"]
end
subgraph "Data Structures & Concurrency"
HEAP["heap.h<br/>Min-Heap"]
MAP["map.h<br/>Generic Map"]
LIST["list.h<br/>Doubly-Linked List"]
ARRAY["array.h<br/>Dynamic Array"]
MPSC["mpsc.h<br/>Lock-Free MPSC Queue"]
ATOMIC["atomic.h<br/>Atomic Operations"]
end
EVENT -->|"registers timers"| TIMER
EVENT -->|"offloads work"| TASK
EVENT -->|"wraps fd"| SOCKET
EVENT -->|"SIGCHLD + I/O watch"| CMD
SOCKET -->|"monitors I/O"| EVENT
SOCKET -->|"idle timeout"| EVENT
TIMER -->|"schedules entries"| HEAP
TIMER -->|"poll-mode queue"| MPSC
TIMER -->|"push-mode dispatch"| TASK
TIMER -->|"reads clock"| TIME
MPSC -->|"CAS operations"| ATOMIC
MEMORY -->|"atomic refcount"| ATOMIC
SLAB -->|"intrusive freelist"| ATOMIC
TIMER -->|"entry allocation"| SLAB
TASK -->|"task allocation"| SLAB
MAP -->|"node allocation"| SLAB
LOG -->|"fatal backtrace"| BACKTRACE
LOG -->|"error formatting"| ERROR
EVENT -->|"reads clock"| TIME
style EVENT fill:#4a90d9,color:#fff
style TIMER fill:#4a90d9,color:#fff
style TASK fill:#4a90d9,color:#fff
style SOCKET fill:#4a90d9,color:#fff
style CMD fill:#4a90d9,color:#fff
style MEMORY fill:#50b86c,color:#fff
style SLAB fill:#50b86c,color:#fff
style LOG fill:#50b86c,color:#fff
style BACKTRACE fill:#50b86c,color:#fff
style ERROR fill:#50b86c,color:#fff
style TIME fill:#50b86c,color:#fff
style HEAP fill:#f5a623,color:#fff
style MAP fill:#f5a623,color:#fff
style LIST fill:#f5a623,color:#fff
style ARRAY fill:#f5a623,color:#fff
style MPSC fill:#f5a623,color:#fff
style ATOMIC fill:#f5a623,color:#fff
Sub-Module Overview
| Header | Document | Description |
|---|---|---|
event.h | event.md | Cross-platform event loop (edge-triggered) — kqueue / epoll / poll backends with built-in timer and thread-pool integration |
timer.h | timer.md | Monotonic timer with push (thread-pool) and poll (lock-free MPSC) fire modes |
task.h | task.md | N:M task model — lightweight tasks multiplexed onto a configurable thread pool |
socket.h | socket.md | Async socket abstraction with idle-timeout support over xEventLoop |
memory.h | memory.md | Reference-counted allocation with vtable-driven lifecycle (ctor/dtor/retain/release) |
slab.h | slab.md | Fixed-size object pool — single-threaded xSlab and thread-safe xSlabMt variants for high-frequency small allocations |
log.h | log.md | Per-thread callback-based logging with optional backtrace on fatal |
backtrace.h | backtrace.md | Platform-adaptive stack trace capture (libunwind > execinfo > stub) |
error.h | error.md | Unified error codes (xErrno) and human-readable messages |
heap.h | heap.md | Generic min-heap with O(log n) insert/remove, used internally by the timer subsystem |
map.h | map.md | Generic key-value map with three backends: hash table, flat table, and red-black tree |
mpsc.h | mpsc.md | Lock-free multi-producer / single-consumer intrusive queue |
atomic.h | atomic.md | Compiler-portable atomic operations (GCC/Clang __atomic builtins) |
io.h | io.md | Abstract I/O interfaces (Reader, Writer, Seeker, Closer) with convenience helpers (xReadFull, xReadAll, xWritev, etc.) |
list.h | list.md | Intrusive doubly-linked circular list — zero-allocation, inline implementation derived from Linux kernel's list.h |
array.h | array.md | Generic auto-growing array — type-erased contiguous storage with optional lifecycle callbacks (retain/release/equal) |
arena.h | arena.md | Fixed-capacity bump allocator — O(1) allocation, O(1) ownership check, no per-object free; ideal for phase-scoped data (parse trees, request buffers) |
hex.h | hex.md | Hex (base16) encode/decode — binary to/from ASCII hex string (lower-case output, case-insensitive decode) |
base64.h | base64.md | Base64 encode/decode (RFC 4648) — standard and URL-safe alphabets, with or without = padding |
random.h | random.md | Cross-platform cryptographically secure random bytes — getrandom / getentropy / BCryptGenRandom with /dev/urandom fallback |
time.h | — | Time utilities: xMonoMs() (monotonic) and xWallMs() (wall-clock) in milliseconds |
cmd.h | cmd.md | Async command executor over xEventLoop — spawn child processes with stdout/stderr capture, streaming, discard, and PTY modes |
flag.h | flag.md | POSIX/GNU-style command-line flag parser — typed storage, auto-generated --help, choice validation, counter and positional support |
fiber.h | fiber.md | Cross-platform lightweight fibers (stackful coroutines) — independent stacks with guard pages, _setjmp/_longjmp on Unix, CreateFiber/SwitchToFiber on Windows |
How to Choose
| I need to… | Use |
|---|---|
| React to I/O readiness on file descriptors | event.h — register fds and get edge-triggered callbacks |
| Schedule delayed or periodic work | timer.h — standalone timer, or use xEventLoopTimerAfter() for event-loop-integrated timers |
| Run CPU-bound work off the main thread | task.h — submit to a thread pool, optionally collect results |
| Post a callback to the event loop from another thread | event.h — xEventLoopPost() for zero-overhead cross-thread dispatch |
| Manage non-blocking TCP/UDP connections | socket.h — wraps socket + event loop + idle timeout |
| Allocate objects with automatic cleanup | memory.h — XMALLOC(T) + xRetain/xRelease |
| Pool many small fixed-size objects with minimal overhead | slab.h — xSlab (ST) / xSlabMt (MT) object pool with intrusive freelist |
| Allocate many objects with a shared lifetime and free them all at once | arena.h — xArena bump allocator; one xArenaDestroy() or xArenaReset() reclaims everything |
| Report errors from library internals | log.h — thread-local callback, or stderr fallback |
| Capture a stack trace for debugging | backtrace.h — xBacktrace() fills a buffer |
| Handle error codes uniformly | error.h — xErrno enum + xstrerror() |
| Build a priority queue | heap.h — generic min-heap with index tracking |
| Store key-value pairs with O(1) or O(log n) access | map.h — generic map with hash, flat, and tree backends |
| Chain elements in an intrusive doubly-linked list | list.h — zero-allocation circular list with xContainerOf entry access |
| Store a growable list of fixed-size elements with automatic cleanup | array.h — xArray with optional retain/release callbacks for per-element resource management |
| Pass messages between threads lock-free | mpsc.h — intrusive MPSC queue |
| Perform atomic read-modify-write | atomic.h — macro wrappers over compiler builtins |
| Get current time in milliseconds | time.h — xMonoMs() for elapsed time, xWallMs() for wall-clock |
| Read/write through abstract I/O interfaces | io.h — xReader / xWriter + helpers like xReadFull, xReadAll |
| Submit a shell command asynchronously | cmd.h — xCommandExecutorSubmit() with capture, stream, or discard output modes |
| Parse command-line arguments | flag.h — xFlagAddString / Int / Bool / Choice / Counter / Positional + xFlagParse with auto-generated --help |
| Yield and resume execution contexts cooperatively | fiber.h — xFiberCreate / xFiberSwitch / xFiberDestroy, integrate with xEventLoop for async I/O |
Quick Start
A minimal example that creates an event loop, schedules a one-shot timer, and runs until the timer fires:
#include <stdio.h>
#include <x/base/event.h>
static void on_timer(void *arg) {
printf("Timer fired!\n");
xEventLoopStop((xEventLoop)arg);
}
int main(void) {
// Create an event loop
xEventLoop loop = xEventLoopCreate();
if (!loop) return 1;
// Schedule a timer to fire after 1 second
xEventLoopTimerAfter(loop, on_timer, loop, 1000);
// Run the event loop (blocks until xEventLoopStop is called)
xEventLoopRun(loop);
// Clean up
xEventLoopDestroy(loop);
return 0;
}
Compile with:
gcc -o example example.c -I/path/to/libx -lxbase -lpthread
Relationship with Other Modules
graph LR
XBASE["xbase"]
XBUF["xbuf"]
XHTTP["xhttp"]
XLOG["xlog"]
XHTTP -->|"event loop + timer"| XBASE
XHTTP -->|"I/O buffers"| XBUF
XLOG -->|"event loop + MPSC queue"| XBASE
XBUF -.->|"no dependency"| XBASE
XNET["xnet"]
XNET -->|"event loop + thread pool + atomic"| XBASE
XHTTP -->|"URL + DNS + TLS config"| XNET
style XBASE fill:#4a90d9,color:#fff
style XBUF fill:#50b86c,color:#fff
style XHTTP fill:#f5a623,color:#fff
style XLOG fill:#e74c3c,color:#fff
style XNET fill:#e74c3c,color:#fff
- xbuf — Buffer module.
xIOBufferuses xbase'satomic.hfor lock-free block pool management. xhttp uses both xbase and xbuf together. - xhttp — The async HTTP client is built on top of xbase's event loop (
xEventLoop) and timer infrastructure, and uses xbuf for response buffering. - xnet — The networking primitives module. The async DNS resolver uses xbase's event loop for thread-pool offload (
xEventLoopSubmit) andatomic.hfor the cancellation flag. Cross-thread notifications (e.g., ICE/TURN completions) can usexEventLoopPost()to avoid thread-pool overhead. - xlog — The async logger uses xbase's event loop for timer-based flushing and the MPSC queue for lock-free log message passing from application threads to the logger thread.
event.h — Cross-Platform Event Loop
Introduction
event.h provides a cross-platform, edge-triggered event loop abstraction for I/O multiplexing. It unifies three OS-specific backends — kqueue (macOS/BSD), epoll (Linux), and poll (POSIX fallback) — behind a single API. The event loop is the central coordination point in xbase: it monitors file descriptors for readiness, dispatches timer callbacks, offloads CPU-bound work to thread pools, and watches for POSIX signals — all from a single thread.
Design Philosophy
-
Edge-Triggered Everywhere — All three backends operate in edge-triggered mode. kqueue uses
EV_CLEAR, epoll usesEPOLLET, and poll emulates edge-triggered behavior by clearing the event mask after each notification (requiring the caller to re-arm viaxEventMod()). This design encourages callers to drain fds completely, reducing spurious wakeups. -
Backend Selection at Compile Time — The backend is chosen via preprocessor macros (
X_HAS_KQUEUE,X_HAS_EPOLL), with poll as the universal fallback. This means zero runtime dispatch overhead. -
Integrated Timer Heap — Rather than requiring a separate timer facility, the event loop embeds a min-heap of timer entries.
xEventLoopRun()automatically adjusts its timeout to fire the earliest timer, providing sub-millisecond timer resolution without a dedicated timer thread. -
Thread-Pool Offload —
xWorkSubmit()bridges the event loop and the task system: CPU-bound work runs on a worker thread, and the completion callback is dispatched on the event loop thread via a lock-free MPSC queue + cross-thread wake, ensuring single-threaded callback semantics. Offloaded work can be cancelled viaxWorkCancel()if it hasn't started yet. -
Direct Cross-Thread Posting —
xEventLoopPost()allows any thread to queue a callback for execution on the event loop thread without involving a thread pool. This is the lightest cross-thread communication primitive — ideal for notifying the loop of external events (e.g., ICE/TURN callbacks, inter-module signals) with zero thread-pool overhead. -
Self-Pipe Trick for Signals — On epoll and poll backends, signal delivery uses the self-pipe trick (a
sigactionhandler writes to a pipe) rather thansignalfd, avoiding the fragile requirement of blocking signals in every thread. On kqueue,EVFILT_SIGNALis used natively. -
Named Loop → Named Thread —
xEventLoopEnter()sets the calling thread's OS name (viapthread_setname_np) to the loop's configured name, making loops visible inps,htop, and debuggers. The name is restored from the previous loop onxEventLoopLeave(). The default is"xEventLoop"— override viaxEventLoopConf.name.
Architecture
graph TD
subgraph "Public API"
ADD["xEventAdd(fd, mask, fn, arg)"]
TIMER["xTimerStart(fn, arg, on_cancel, timeout, repeat)"]
WORK["xWorkSubmit(group, work_fn, done_fn, arg)"]
POST["xEventLoopPost(loop, fn, arg)"]
SIGNAL["xSignal(signo, fn, arg)"]
end
subgraph "Event Loop Thread"
RUN["xEventLoopRun(mode)"]
subgraph "Per-Iteration Pipeline"
DONE1["loop_run_done<br/>drain done queue (batch 16)"]
POLL["loop_poll_and_dispatch<br/>backend.poll() + I/O dispatch"]
DONE2["loop_run_done<br/>drain done queue (batch 16)"]
TIME["loop_update_time<br/>update monotonic clock"]
FIRE["loop_run_timers<br/>pop & fire expired timers"]
SWEEP["loop_sweep<br/>free deleted sources"]
end
TL_LOOP["tl_loop (thread-local)"]
end
subgraph "Data Structures"
SOURCES["Source Array<br/>(deferred-deletion)"]
HEAP["Timer Min-Heap<br/>(O(log n) push/pop)"]
DONE_Q["Done Queue<br/>(lock-free MPSC)"]
SIGNALS["Signal Watches<br/>(per-signo slots, max 64)"]
end
subgraph "Backend (compile-time vtable)"
KQ["kqueue<br/>EV_CLEAR, EVFILT_USER"]
EP["epoll<br/>EPOLLET, eventfd"]
PO["poll<br/>emulated edge, pipe"]
end
subgraph "Cross-Thread"
WAKE["xEventLoopWake<br/>atomic coalescing"]
POOL["Task Pool<br/>(worker threads)"]
end
ADD --> SOURCES
TIMER --> HEAP
WORK --> POOL
POST --> DONE_Q
SIGNAL --> SIGNALS
RUN --> DONE1
DONE1 --> POLL
POLL --> DONE2
DONE2 --> TIME
TIME --> FIRE
FIRE --> SWEEP
SWEEP --> DONE1
POLL --> KQ
POLL --> EP
POLL --> PO
POOL -->|"push result"| DONE_Q
WAKE -->|"trigger"| POLL
style RUN fill:#4a90d9,color:#fff
style POLL fill:#4a90d9,color:#fff
style HEAP fill:#f5a623,color:#fff
style DONE_Q fill:#50b86c,color:#fff
Event Loop Lifecycle
sequenceDiagram
participant App
participant EL as xEventLoop
participant Backend as kqueue / epoll / poll
participant Timer as Timer Heap
participant DoneQ as MPSC Done Queue
participant Pool as Worker Pool
App->>EL: xEventLoopCreate()
App->>EL: xEventAdd(fd, mask, callback)
App->>EL: xTimerStart(on_timer, arg, NULL, 1000, 0)
App->>EL: xWorkSubmit(group, work, done, arg)
Pool-->>DoneQ: push result (async)
App->>EL: xEventLoopRun(X_RUN_DEFAULT)
loop Main Loop
EL->>DoneQ: loop_run_done(batch 16)
EL->>Timer: Check earliest deadline
Timer-->>EL: timeout = min(deadline, -1)
EL->>Backend: backend.poll(timeout)
Backend-->>EL: I/O events + signals
EL->>App: callback(fd, mask)
EL->>DoneQ: loop_run_done(batch 16)
EL->>EL: update monotonic time
EL->>Timer: Pop & fire expired timers
EL->>EL: Sweep deleted sources
end
App->>EL: xEventLoopStop()
App->>EL: xEventLoopDestroy()
API Reference
Types
| Type | Description |
|---|---|
xEventMask | Bitmask enum: xEvent_Read (1), xEvent_Write (2), xEvent_Timeout (4) |
xEventFunc | void (*)(int fd, xEventMask mask, void *arg) — I/O callback |
xTimerFunc | void (*)(void *arg) — Timer callback |
xSignalFunc | void (*)(int signo, void *arg) — Signal callback |
xWorkDoneFunc | void (*)(void *arg, void *result) — Offload completion callback |
xEventLoopPostFunc | void (*)(void *arg) — Posted callback (via xEventLoopPost) |
xEventLoop | Opaque handle to an event loop |
xEventSource | Opaque handle to a registered event source |
xTimer | Opaque handle to a builtin timer |
xWork | Opaque handle to a submitted offload work item |
Functions
Lifecycle
| Function | Signature | Thread Safety |
|---|---|---|
xEventLoopCreate | xEventLoop xEventLoopCreate(void) | Not thread-safe |
xEventLoopCreateWithConf | xEventLoop xEventLoopCreateWithConf(const xEventLoopConf *conf) | Not thread-safe |
xEventLoopCreateWithGroup | xEventLoop xEventLoopCreateWithGroup(xTaskGroup group) | Not thread-safe |
xEventLoopDestroy | void xEventLoopDestroy(xEventLoop loop) | Not thread-safe |
xEventLoopRun | int xEventLoopRun(xEventLoop loop, int mode) | Not thread-safe (call from one thread) |
xEventLoopStop | void xEventLoopStop(xEventLoop loop) | Thread-safe |
xEventLoopEnter | void xEventLoopEnter(xEventLoop loop) | Not thread-safe |
xEventLoopLeave | void xEventLoopLeave(void) | Not thread-safe |
xEventLoopCurrent | xEventLoop xEventLoopCurrent(void) | Thread-safe |
xEventLoopGlobal | xEventLoop xEventLoopGlobal(void) | Not thread-safe |
xEventLoopFd | int xEventLoopFd(xEventLoop loop) | Not thread-safe |
xEventLoopNextTimeout | int xEventLoopNextTimeout(xEventLoop loop) | Not thread-safe |
I/O Sources
| Function | Signature | Thread Safety |
|---|---|---|
xEventAdd | xEventSource xEventAdd(int fd, xEventMask mask, xEventFunc fn, void *arg) | Not thread-safe |
xEventMod | xErrno xEventMod(xEventSource src, xEventMask mask) | Not thread-safe |
xEventDel | xErrno xEventDel(xEventSource src) | Not thread-safe |
Timers
| Function | Signature | Thread Safety |
|---|---|---|
xTimerStart | xTimer xTimerStart(xTimerFunc fn, void *arg, xTimerFunc on_cancel, uint64_t timeout_ms, uint64_t repeat_ms) | Not thread-safe |
xTimerStop | xErrno xTimerStop(xTimer timer) | Thread-safe |
Cross-Thread
| Function | Signature | Thread Safety |
|---|---|---|
xEventLoopWake | xErrno xEventLoopWake(xEventLoop loop) | Thread-safe (signal-handler-safe) |
xEventLoopPost | xErrno xEventLoopPost(xEventLoop loop, xEventLoopPostFunc fn, void *arg) | Thread-safe |
xWorkSubmit | xWork xWorkSubmit(xTaskGroup group, xTaskFunc work_fn, xWorkDoneFunc done_fn, void *arg) | Thread-safe |
xWorkCancel | xErrno xWorkCancel(xWork work) | Thread-safe |
Signal
| Function | Signature | Thread Safety |
|---|---|---|
xSignal | xErrno xSignal(int signo, xSignalFunc fn, void *arg) | Not thread-safe |
Run Modes
| Constant | Value | Description |
|---|---|---|
X_RUN_DEFAULT | -1 | Block until xEventLoopStop() or no active handles |
X_RUN_ONCE | -2 | Single iteration, block until at least one event |
X_RUN_NOWAIT | -3 | Single iteration, non-blocking poll |
Usage Examples
Basic Event Loop with Timer
#include <stdio.h>
#include <x/base/event.h>
static void on_timer(void *arg) {
printf("Timer fired!\n");
xEventLoopStop((xEventLoop)arg);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
if (!loop) return 1;
// Fire after 500ms, one-shot (repeat_ms = 0)
xTimerStart(on_timer, loop, NULL, 500, 0);
xEventLoopRun(loop, X_RUN_DEFAULT);
xEventLoopDestroy(loop);
return 0;
}
Monitoring a File Descriptor
#include <stdio.h>
#include <unistd.h>
#include <x/base/event.h>
static void on_readable(int fd, xEventMask mask, void *arg) {
char buf[1024];
ssize_t n;
// Edge-triggered: must drain completely
while ((n = read(fd, buf, sizeof(buf))) > 0) {
fwrite(buf, 1, (size_t)n, stdout);
}
(void)mask;
(void)arg;
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
// Monitor stdin for readability (loop obtained from thread-local context)
xEventAdd(STDIN_FILENO, xEvent_Read, on_readable, NULL);
// Run for up to 10 seconds, then stop
xTimerStart((xTimerFunc)xEventLoopStop, loop, NULL, 10000, 0);
xEventLoopRun(loop, X_RUN_DEFAULT);
xEventLoopDestroy(loop);
return 0;
}
Bounded Wait with Timeout
#include <stdio.h>
#include <x/base/event.h>
static void on_timer(void *arg) {
printf("Work complete!\n");
xEventLoopStop((xEventLoop)arg);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xTimerStart(on_timer, loop, NULL, 500, 0);
// Run loop with timer-driven stop after 500ms
xEventLoopRun(loop, X_RUN_DEFAULT);
xEventLoopDestroy(loop);
return 0;
}
Posting a Callback to the Loop Thread
#include <stdio.h>
#include <pthread.h>
#include <x/base/event.h>
static void on_notify(void *arg) {
// Runs on the event loop thread — safe to access loop state
printf("Notified from another thread!\n");
xEventLoopStop((xEventLoop)arg);
}
static void *background_thread(void *arg) {
xEventLoop loop = (xEventLoop)arg;
// Do some work...
xEventLoopPost(loop, on_notify, loop);
return NULL;
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
pthread_t th;
pthread_create(&th, NULL, background_thread, loop);
xEventLoopRun(loop, X_RUN_DEFAULT);
pthread_join(th, NULL);
xEventLoopDestroy(loop);
return 0;
}
Offloading Work to a Thread Pool
#include <stdio.h>
#include <x/base/event.h>
static void *heavy_work(void *arg) {
// Runs on a worker thread
int *val = (int *)arg;
*val *= 2;
return val;
}
static void on_done(void *arg, void *result) {
// Runs on the event loop thread
int *val = (int *)result;
printf("Result: %d\n", *val);
(void)arg;
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
int value = 21;
xWorkSubmit(NULL, heavy_work, on_done, &value);
// Run briefly to process the completion
xTimerStart((xTimerFunc)xEventLoopStop, loop, NULL, 1000, 0);
xEventLoopRun(loop, X_RUN_DEFAULT);
xEventLoopDestroy(loop);
return 0;
}
Cancelling Offloaded Work
#include <stdio.h>
#include <x/base/event.h>
static void *slow_work(void *arg) {
// Simulate long-running work
sleep(5);
return NULL;
}
static void on_done(void *arg, void *result) {
(void)result;
printf("Work completed (should not print if cancelled)\n");
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xWork work = xWorkSubmit(NULL, slow_work, on_done, NULL);
if (!work) return 1;
// Cancel before work starts — done_fn won't be called
xErrno rc = xWorkCancel(work);
if (rc == xErrno_Ok) {
printf("Cancelled successfully\n");
}
xEventLoopLeave();
xEventLoopDestroy(loop);
return 0;
}
Watching POSIX Signals
#include <stdio.h>
#include <signal.h>
#include <x/base/event.h>
static void on_signal(int signo, void *arg) {
printf("Received signal %d\n", signo);
xEventLoopStop((xEventLoop)arg);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
// Watch SIGUSR1 — callback runs on the event loop thread
xSignal(SIGUSR1, on_signal, loop);
// Cancel the watch (restore SIG_DFL)
// xSignal(SIGUSR1, NULL, NULL);
xEventLoopRun(loop, X_RUN_DEFAULT);
xEventLoopDestroy(loop);
return 0;
}
Repeating Timer
#include <stdio.h>
#include <x/base/event.h>
static int count = 0;
static void on_tick(void *arg) {
xEventLoop loop = (xEventLoop)arg;
printf("Tick %d\n", ++count);
if (count >= 5) {
xEventLoopStop(loop);
}
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
// Fire every 200ms (repeat_ms > 0 for repeating)
xTimerStart(on_tick, loop, NULL, 200, 200);
xEventLoopRun(loop, X_RUN_DEFAULT);
xEventLoopDestroy(loop);
return 0;
}
Pumping the Loop in Tests
#include <assert.h>
#include <x/base/event.h>
static int callback_count = 0;
static void on_timer(void *arg) {
callback_count++;
xEventLoopStop((xEventLoop)arg);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xTimerStart(on_timer, loop, NULL, 100, 0);
// Pump one iteration at a time (blocks until event or timer fires)
for (int elapsed = 0; elapsed < 500 && callback_count == 0; elapsed += 10) {
xEventLoopRun(loop, X_RUN_ONCE);
}
assert(callback_count == 1);
xEventLoopDestroy(loop);
return 0;
}
Embedding in an External Run Loop
#include <stdio.h>
#include <x/base/event.h>
int main(void) {
xEventLoop loop = xEventLoopCreate();
if (!loop) return 1;
// Register a repeating timer
xTimerStart((xTimerFunc)(void (*)(void *))puts, "tick", NULL, 0, 500);
// Get the backend fd for embedding (kqueue fd, epoll fd, etc.)
int fd = xEventLoopFd(loop);
// Manual pump loop — useful for integrating into CFRunLoop,
// Android Looper, or any external event system
for (int i = 0; i < 5; i++) {
int timeout = xEventLoopNextTimeout(loop);
printf("Next timer in %d ms (fd=%d)\n", timeout, fd);
// In a real integration, you'd add fd to the external loop
// with the computed timeout, then call:
xEventLoopRun(loop, X_RUN_ONCE);
}
xEventLoopDestroy(loop);
return 0;
}
Use Cases
-
Network Servers — Register listening sockets and accepted connections with the event loop. Use edge-triggered callbacks to read/write data without blocking. Combine with
xSocketfor idle-timeout support. -
Timer-Driven State Machines — Use
xTimerStart()to schedule state transitions, retries, or heartbeat checks. The timer is integrated into the event loop, so no separate timer thread is needed. -
Hybrid I/O + CPU Workloads — Use
xWorkSubmit()to offload CPU-intensive parsing or compression to a thread pool, then process results on the event loop thread where I/O state is safely accessible. UsexWorkCancel()to cancel pending work when the associated resource is being released. -
Cross-Thread Notifications — Use
xEventLoopPost()to notify the event loop from external callbacks (e.g., ICE/TURN completions, OS notifications) without the overhead of a thread pool round-trip. The callback runs on the loop thread, so no additional synchronisation is needed.
Best Practices
- Always drain fds in edge-triggered mode. Read/write until
EAGAINin every callback. Missing data means you won't be notified again until new data arrives. - Never block in callbacks. The event loop is single-threaded; a blocking call stalls all I/O and timer processing. Offload heavy work via
xWorkSubmit(). - Prefer
xEventLoopPost()overxWorkSubmit()when no worker thread is needed. If you just need to run a callback on the loop thread from another thread,xEventLoopPost()avoids the thread-pool overhead entirely. - Use
xEventLoopRun()for the main loop. PassX_RUN_DEFAULTfor indefinite blocking,X_RUN_ONCEfor a single blocking iteration, orX_RUN_NOWAITfor non-blocking poll. For tests, pump the loop manually withX_RUN_ONCEin a loop with a timeout counter. - Cancel offloaded work when releasing resources. If you submit work via
xWorkSubmit()and the associated resource (passed asarg) is about to be freed, usexWorkCancel()to prevent use-after-free. If cancel succeeds (xErrno_Ok), the arg is safe to free immediately. If it fails (xErrno_InvalidState), the work is already running — letdone_fnhandle cleanup. - Cancel timers you no longer need. Uncancelled timers hold memory until they fire. Use
xTimerStop()to free them early. - Be aware of the poll backend's edge emulation. On systems without kqueue or epoll, the poll backend clears the event mask after dispatch. You must call
xEventMod()to re-arm.
Comparison with Other Libraries
| Feature | xbase event.h | libevent | libev | libuv |
|---|---|---|---|---|
| Trigger Mode | Edge-triggered only | Level (default), edge optional | Level + edge | Level-triggered |
| Backends | kqueue, epoll, poll | kqueue, epoll, poll, select, devpoll, IOCP | kqueue, epoll, poll, select, port | kqueue, epoll, poll, IOCP |
| Timer Integration | Built-in min-heap | Separate timer API | Built-in | Built-in |
| Thread Pool | Built-in (xEventLoopSubmit) | None (external) | None (external) | Built-in (uv_queue_work) |
| Signal Handling | Self-pipe / EVFILT_SIGNAL | evsignal | ev_signal | uv_signal |
| API Style | Opaque handles, C99 | Struct-based, C89 | Struct-based, C89 | Handle-based, C99 |
| Binary Size | ~15 KB | ~200 KB | ~50 KB | ~500 KB |
| Dependencies | None | None | None | None |
| Windows Support | Not yet | Yes (IOCP) | Yes (select) | Yes (IOCP) |
| Design Goal | Minimal building block | Full-featured framework | Minimal + performant | Cross-platform framework |
Key Differentiator: xbase's event loop is intentionally minimal — it provides the essential primitives (I/O, timers, signals, thread-pool offload) without buffered I/O, DNS resolution, or HTTP parsing. This makes it ideal as a foundation layer for higher-level libraries (like xhttp) rather than a standalone application framework.
Benchmark
Environment: Apple M3 Pro, 36 GB RAM, macOS 26.4, Release build (
-O2), kqueue backend. Source:xbase/event_bench.cpp
Core Operations
| Benchmark | Time (ns) | CPU (ns) | Iterations |
|---|---|---|---|
BM_EventLoop_CreateDestroy | 700 | 700 | 974,157 |
BM_EventLoop_WakeLatency | 413 | 413 | 1,717,088 |
BM_EventLoop_PipeAddDel | 1,144 | 1,144 | 612,118 |
- Create/Destroy takes ~700ns — reduced from ~2.8µs after eliminating the wake pipe (no more
pipe()+ two extra fds). - Wake latency is ~413ns per wake+wait cycle via
EVFILT_USER, down from ~879ns with the old pipe mechanism — a 2.1× improvement.
libuv Baseline Comparison
| Dimension | libx | libuv | Ratio |
|---|---|---|---|
| Wake Latency | 413 ns | 417 ns | Tied (libx 1.01× faster) |
| Timer (single) | 461 ns | 1,517 ns | libx 3.3× faster |
| Timer (×1000) | 43,545 ns | 68,659 ns | libx 1.6× faster |
| Offload (single) | 3,785 ns | 3,449 ns | libuv 1.1× faster (tied) |
| Offload (×1000) | 456,426 ns | 218,513 ns | libuv 2.1× faster |
Key Observations:
- Wake latency — Now effectively tied with libuv (413ns vs 417ns) after switching to
EVFILT_USER(kqueue) /eventfd(epoll) + atomic wake coalescing. Previously 2.1× slower. - Timer — libx now wins across all batch sizes thanks to batch-pop with single lock acquisition and timer struct freelist pooling. Previously libuv was 4–5× faster at batch sizes.
- Offload round-trip — libuv remains ~2× faster at scale. The gap has narrowed at small batch sizes thanks to wake coalescing and work item pooling.
Implementation Details
Backend Architecture
Each backend is implemented in a separate .c file that provides the full public API:
| File | Backend | Trigger Mode | Selection |
|---|---|---|---|
event_kqueue.c | kqueue | EV_CLEAR (native edge) | #ifdef X_HAS_KQUEUE |
event_epoll.c | epoll | EPOLLET (native edge) | #ifdef X_HAS_EPOLL |
event_poll.c | poll(2) | Emulated edge (mask cleared after dispatch) | Fallback |
All backends share a common base structure (struct xEventLoop_) defined in event_private.h, which contains:
- A dynamic source array with deferred deletion (sweep after dispatch)
- A cross-thread wake mechanism (
EVFILT_USERon kqueue,eventfdon epoll, pipe on poll) with atomic coalescing - A min-heap for builtin timers (protected by
timer_mumutex) - A lock-free MPSC done-queue for offload completion and posted callbacks
- Signal watch slots (up to
X_SIGNAL_MAX = 64)
Deferred Source Deletion
When xEventDel() is called during a callback dispatch, the source is marked deleted = 1 rather than freed immediately. After the dispatch batch completes, source_array_sweep() frees all deleted sources. This prevents use-after-free when multiple events reference the same source in a single dispatch cycle.
Cross-Thread Wake
Each backend uses the lightest available mechanism for cross-thread wakeup:
| Backend | Mechanism | Fds Used |
|---|---|---|
| kqueue | EVFILT_USER with NOTE_TRIGGER | 0 (kernel event, no fd) |
| epoll | eventfd (EFD_NONBLOCK | EFD_CLOEXEC) | 1 (wake_rfd) |
| poll | Non-blocking pipe (wake_rfd / wake_wfd) | 2 (POSIX fallback) |
xEventLoopWake() triggers the backend-specific notification; the event loop drains it and processes the done-queue. Multiple wakes before the next xEventLoopRun() iteration are coalesced via an atomic wake_pending flag — only the first caller after the loop clears the flag performs the actual syscall, subsequent callers skip it entirely. This reduces wake overhead from O(N) syscalls to O(1) in batch completion scenarios.
Timer Integration
Builtin timers are stored in a min-heap inside the event loop. Before each polling call, the effective timeout is clamped to the earliest timer deadline. After I/O dispatch, expired timers are popped and fired. Timer operations (xTimerStart, xTimerStop) are thread-safe, protected by timer_mu.
xTimerStart(fn, arg, NULL, timeout_ms, repeat_ms) combines the old xEventLoopTimerAfter (one-shot) and xEventLoopTimerAt (absolute time) into a single function. Pass repeat_ms = 0 for one-shot behavior, or a positive value for repeating timers.
Signal Handling
| Backend | Mechanism |
|---|---|
| kqueue | EVFILT_SIGNAL with EV_CLEAR — native kernel support |
| epoll | Self-pipe trick: sigaction handler writes to a per-signal pipe |
| poll | Self-pipe trick: same as epoll |
The self-pipe approach avoids signalfd's requirement to block signals in all threads, which is fragile in the presence of third-party libraries and test frameworks.
fiber.h — Cross-Platform Lightweight Fibers
Introduction
fiber.h provides a minimal C API for user-space cooperative multitasking — stackful coroutines (fibers) with their own call stack. Designed to integrate with xEventLoop: a fiber suspends itself, the event loop drives I/O, and the waker switches the fiber back in.
Modeled after the Windows Fiber API (CreateFiber / SwitchToFiber), but unified across Unix (mmap + swapcontext + makecontext/setcontext) and Windows (CreateFiberEx / SwitchToFiber / DeleteFiber).
Design Philosophy
-
Minimal Surface — Seven functions. No scheduler, no message passing, no preemption. Fibers yield voluntarily via
xFiberSwitch()orxFiberYield(). Higher-level scheduling is the caller's responsibility (e.g., event loop + waker integration). -
Independent Stacks — Each fiber gets its own stack with a guard page (
PROT_NONEon Unix, OS-managed on Windows). Stack overflow triggersSIGSEGV/ access violation deterministically instead of corrupting adjacent memory. -
Thread-Local — All operations are single-threaded per fiber set.
xFiberSwitch()must only switch between fibers on the same thread. The TLS slot (tl_fiber) isolates independent fiber sets across threads with zero synchronization. -
API Parity — The public API has identical signatures and semantics on Unix and Windows. Platform differences are confined to the implementation files (
fiber.c/fiber_win.c). -
Parent-Child Chain — Each fiber records its parent at creation time.
xFiberYield()switches back to the parent (or the main fiber if none), enabling nested fiber hierarchies — a child fiber can.wait()a grandchild promise and get the result directly, without bouncing through the main fiber.
Architecture
┌─────────────┐
│ xFiberMain │ ← Convert thread, get main fiber
└──────┬──────┘
│ (parent = NULL)
┌─────────────────┼─────────────────┐
│ │ │
┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐
│ Fiber A │ │ Fiber B │ │ Fiber C │
│ (64 KiB) │ │ (64 KiB) │ │ (128 KiB) │
│ parent=main │ │ parent=main │ │ parent=A │
└──────────────┘ └──────────────┘ └──────────────┘
Fiber C's parent = A:
xFiberYield() in C → resumes A
xFiberSwitch(A): main → A → main
xFiberSwitch(B): main → B → main
xFiberYield() in C: C → A (skips main)
A spawns C: A → C → A (C's yield goes back to A)
All fibers share one thread. Only one fiber runs at a time.
Stack Layout (Unix)
High addr
┌──────────────────────────┐
│ usable stack (RW) │ ← sp starts here, grows downward
│ default 64 KiB │
├──────────────────────────┤ ← stack_base
│ guard page (PROT_NONE) │ ← touch → SIGSEGV
└──────────────────────────┘ ← stack (mmap return)
Low addr
API Reference
Types
| Type | Description |
|---|---|
xFiber | Opaque handle. Represents either a main fiber (thread) or a child fiber. |
xFiberProc | typedef void (*xFiberProc)(void *arg). Fiber entry point. |
Functions
| Function | Signature | Description |
|---|---|---|
xFiberMain | xFiber xFiberMain(void) | Convert the current thread. Idempotent. Returns the main fiber handle. |
xFiberCreate | xFiber xFiberCreate(size_t stack_size, xFiberProc proc, void *arg) | Create a fiber on a new stack. Does NOT start execution. Returns NULL on failure. Also records the current fiber as parent for xFiberYield(). |
xFiberDestroy | void xFiberDestroy(xFiber fiber) | Delete a finished fiber and free its stack. Safe with NULL. |
xFiberSwitch | void xFiberSwitch(xFiber target) | Suspend current fiber, resume target. Implicitly calls xFiberMain() if needed. |
xFiberYield | void xFiberYield(void) | Suspend current fiber and switch back to its parent (falling back to main if no parent). This is the primitive used by higher-level APIs (PromiseContext::park(), xpp::fiber trampoline) — xFiberSwitch(xFiberMain()) is almost never what you want. |
xFiberCurrent | xFiber xFiberCurrent(void) | Return the currently executing fiber, or NULL if unconverted. |
Lifecycle
xFiberMain() → main fiber handle
xFiberCreate() → child fiber (stack allocated, not started; parent = current)
xFiberSwitch() → start / yield / resume (target can be any fiber)
xFiberYield() → suspend current, resume parent (no target needed)
xFiberDestroy() → free stack, free descriptor
A fiber that finishes its proc() MUST switch back to main
(or to another fiber) — never return to the uc_link (NULL),
which has undefined behavior.
Nested fibers:
main → fiber A → fiber B
B calls xFiberYield() → resumes A (B's parent)
A calls xFiberYield() → resumes main (A's parent)
xFiberSwitch() can still jump to any fiber, bypassing the parent chain.
Usage Examples
Basic Round Trip
#include <assert.h>
#include <x/base/fiber.h>
static bool visited = false;
static void my_proc(void *arg) {
xFiber *main = (xFiber *)arg;
visited = true;
xFiberSwitch(*main); /* yield back to caller */
}
int main(void) {
xFiber main = xFiberMain();
xFiber child = xFiberCreate(0, my_proc, &main);
assert(child != NULL);
xFiberSwitch(child);
assert(visited);
xFiberDestroy(child);
return 0;
}
Multiple Yields (Generator Pattern)
static void counter_proc(void *arg) {
int *ctx = (int *)arg;
for (int i = 0; i < (*ctx); i++) {
xFiberSwitch(g_main); /* yield after each increment */
}
xFiberSwitch(g_main); /* final yield */
}
/* main: drive the fiber N times */
for (int i = 0; i < N; i++) {
xFiberSwitch(fiber);
/* fiber just yielded, ctx->counter has been incremented */
}
Fiber Chaining
/* Fiber A spawns Fiber B, B yields back to A via xFiberYield(). */
static void proc_b(void *arg) {
int *val = (int *)arg;
*val = 42;
xFiberYield(); /* resume parent (A) */
}
static void proc_a(void *arg) {
int result = 0;
xFiber b = xFiberCreate(0, proc_b, &result); /* b.parent = A */
xFiberSwitch(b); /* A → B */
/* B yielded back → result is 42 now */
assert(result == 42);
xFiberDestroy(b);
xFiberYield(); /* resume main */
}
/* Main: */
xFiberSwitch(a); /* main → A */
/* A back: fiber completed */
xFiberDestroy(a);
Yield Without Knowing Parent
/* Any fiber can call xFiberYield() — no need to track who the parent is. */
static void worker(void *arg) {
int *counter = (int *)arg;
for (int i = 0; i < 10; i++) {
(*counter)++;
xFiberYield(); /* back to whoever spawned me */
}
}
Platform Notes
Unix (Linux / macOS / BSD)
| Aspect | Detail |
|---|---|
| Stack allocation | `mmap(MAP_PRIVATE |
| Guard page | mprotect(PROT_NONE) on the bottom page |
| First entry | makecontext + setcontext |
| Yield / resume | swapcontext (atomically saves current ucontext and restores target; POSIX-blessed for cross-stack switching) |
| macOS arm64 note | makecontext variadic args cannot pass 64-bit pointers — trampoline reads proc/proc_arg from the fiber descriptor via TLS |
Windows
| Aspect | Detail |
|---|---|
| Stack allocation | CreateFiberEx with FIBER_FLAG_FLOAT_SWITCH (preserves FPU/SSE/AVX) |
| Guard page | OS-managed via VirtualAlloc |
| First entry | SwitchToFiber enters the trampoline automatically |
| Yield / resume | SwitchToFiber (kernel-assisted fiber dispatcher on x64) |
Architecture Integration
With xEventLoop
The fiber API is designed to integrate with xEventLoop for async I/O:
Fiber suspends in Promise::wait()
→ xFiberYield() — switch back to parent (or main)
→ xEventLoopRun(ONCE) — process I/O events
→ Promise resolves, waker fires
→ xFiberSwitch(fiber) — resume the waiting fiber
This is the core mechanism for xpp::fiber().
Thread Safety
xFiberSwitch(): Single-thread only — must switch between fibers on the same threadxFiberCreate()/xFiberDestroy(): Single-thread — operates on the calling thread's fiber setxFiberCurrent(): Thread-safe — returns the calling thread's fiberxFiberMain(): Thread-safe — idempotent per thread
Diagnostics
| Condition | Behavior |
|---|---|
xFiberSwitch(NULL) | Silent no-op (returns immediately) |
xFiberDestroy(current_fiber) | Silent no-op (returns immediately) |
| Fiber proc returns without switching | abort() — fibers are a deterministic system and undefined transitions must fail hard |
xFiberCreate allocation failure | Returns NULL |
xFiberYield from main thread | Silent no-op |
xFiberYield from root fiber (parent = NULL) | Switches to main fiber |
See Also
event.h— Event loop that drives fiberspromise.h(libxpp) —wait()integrates with fibers for non-blocking I/Ofiber.h(libxpp) —xpp::fiber()high-level API built on top of this module
task.h — N:M Task Model
Introduction
task.h provides a lightweight N:M concurrent task model where N user tasks are multiplexed onto M OS threads managed by a task group (thread pool). It supports lazy thread creation, configurable queue capacity, per-task result retrieval, and a global shared task group for convenience.
Design Philosophy
-
Lazy Thread Spawning — Worker threads are created on-demand when tasks are submitted and no idle thread is available, up to the configured maximum. This avoids pre-allocating threads that may never be used, reducing resource consumption for bursty workloads.
-
Simple Submit/Wait Model — Tasks are submitted with
xTaskSubmit()and optionally awaited withxTaskWait(). This mirrors the future/promise pattern found in higher-level languages, but in pure C with minimal overhead. -
Safe Cancellation —
xTaskCancel()uses a single CAS (compare-and-swap) to atomically transition a queued task to the cancelled state. If the task is still in the queue, the cancel succeeds and the caller can safely release the task's argument. If the task is already running or done, the cancel fails and the caller mustxTaskWait()first. -
Configurable Capacity — The task group can be configured with a maximum thread count and queue capacity. When the queue is full,
xTaskSubmit()returns NULL, giving the caller explicit backpressure. -
Global Shared Group —
xTaskGroupGlobal()provides a lazily-initialized, process-wide task group with default settings (unlimited threads, no queue cap). It's automatically destroyed atatexit(), making it convenient for fire-and-forget usage.
Architecture
graph TD
subgraph "Task Group"
QUEUE["Task Queue (FIFO)"]
W1["Worker Thread 1"]
W2["Worker Thread 2"]
WN["Worker Thread N"]
end
APP["Application"] -->|"xTaskSubmit()"| QUEUE
QUEUE -->|"dequeue"| W1
QUEUE -->|"dequeue"| W2
QUEUE -->|"dequeue"| WN
W1 -->|"done"| RESULT["xTaskWait() → result"]
W2 -->|"done"| RESULT
WN -->|"done"| RESULT
style APP fill:#4a90d9,color:#fff
style QUEUE fill:#f5a623,color:#fff
style RESULT fill:#50b86c,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xTaskFunc | void *(*)(void *arg) — Task function signature. Returns a result pointer. |
xTask | Opaque handle to a submitted task |
xTaskGroup | Opaque handle to a task group (thread pool) |
xTaskGroupConf | Configuration struct: nthreads (0 = auto), queue_cap (0 = unbounded) |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xTaskGroupCreate | xTaskGroup xTaskGroupCreate(const xTaskGroupConf *conf) | Create a task group. NULL conf = defaults. | Not thread-safe |
xTaskGroupDestroy | void xTaskGroupDestroy(xTaskGroup g) | Wait for pending tasks, then destroy. | Not thread-safe |
xTaskSubmit | xTask xTaskSubmit(xTaskGroup g, xTaskFunc fn, void *arg) | Submit a task. Returns NULL if queue is full. | Thread-safe |
xTaskWait | xErrno xTaskWait(xTask t, void **result) | Block until task completes. Returns xErrno_Cancelled if the task was cancelled. | Thread-safe |
xTaskCancel | xErrno xTaskCancel(xTask t) | Cancel a queued task. Returns xErrno_Ok on success, xErrno_Busy if already running/done. | Thread-safe |
xTaskGroupWait | xErrno xTaskGroupWait(xTaskGroup g) | Block until all pending tasks complete. | Thread-safe |
xTaskGroupThreads | size_t xTaskGroupThreads(xTaskGroup g) | Return number of spawned worker threads. | Thread-safe (atomic read) |
xTaskGroupPending | size_t xTaskGroupPending(xTaskGroup g) | Return number of pending tasks. | Thread-safe (atomic read) |
xTaskGroupGlobal | xTaskGroup xTaskGroupGlobal(void) | Get the global shared task group (lazy init). | Thread-safe |
Usage Examples
Basic Task Submission
#include <stdio.h>
#include <x/base/task.h>
static void *compute(void *arg) {
int *val = (int *)arg;
*val *= 2;
return val;
}
int main(void) {
xTaskGroup group = xTaskGroupCreate(NULL);
int value = 21;
xTask task = xTaskSubmit(group, compute, &value);
void *result;
xTaskWait(task, &result);
printf("Result: %d\n", *(int *)result); // 42
xTaskGroupDestroy(group);
return 0;
}
Parallel Map
#include <stdio.h>
#include <x/base/task.h>
#define N 8
static void *square(void *arg) {
int *val = (int *)arg;
*val = (*val) * (*val);
return val;
}
int main(void) {
xTaskGroupConf conf = { .nthreads = 4, .queue_cap = 0 };
xTaskGroup group = xTaskGroupCreate(&conf);
int data[N] = {1, 2, 3, 4, 5, 6, 7, 8};
xTask tasks[N];
for (int i = 0; i < N; i++)
tasks[i] = xTaskSubmit(group, square, &data[i]);
// Wait for all
xTaskGroupWait(group);
for (int i = 0; i < N; i++)
printf("data[%d] = %d\n", i, data[i]);
// Clean up task handles
for (int i = 0; i < N; i++)
xTaskWait(tasks[i], NULL);
xTaskGroupDestroy(group);
return 0;
}
Cancelling a Task
#include <stdio.h>
#include <stdlib.h>
#include <x/base/task.h>
static void *process(void *arg) {
int *data = (int *)arg;
printf("Processing: %d\n", *data);
return NULL;
}
int main(void) {
xTaskGroup group = xTaskGroupCreate(NULL);
int *data = (int *)malloc(sizeof(int));
*data = 42;
xTask task = xTaskSubmit(group, process, data);
// Try to cancel — if successful, we can safely free data now.
if (xTaskCancel(task) == xErrno_Ok) {
free(data); // Safe: fn was never called
} else {
// Task is already running — must wait before freeing
xTaskWait(task, NULL);
free(data);
}
xTaskGroupDestroy(group);
return 0;
}
Using the Global Task Group
#include <stdio.h>
#include <x/base/task.h>
static void *work(void *arg) {
printf("Running on global pool: %s\n", (char *)arg);
return NULL;
}
int main(void) {
xTask t = xTaskSubmit(xTaskGroupGlobal(), work, "hello");
xTaskWait(t, NULL);
// No need to destroy the global group
return 0;
}
Use Cases
-
CPU-Bound Parallel Processing — Distribute computation across multiple cores. Use
xTaskGroupWait()to synchronize at barriers. -
Event Loop Offload — The event loop's
xEventLoopSubmit()usesxTaskGroupinternally to run work functions on worker threads, then delivers results back to the loop thread. -
Background I/O — Offload blocking file I/O (e.g.,
fsync, large reads) to a thread pool to keep the main thread responsive.
Best Practices
- Always call
xTaskWait()or letxTaskGroupDestroy()clean up. EachxTaskSubmit()allocates a task struct (from the TLS freelist or malloc). Task memory is reclaimed when the done queue is drained (duringxTaskGroupWait()orxTaskGroupDestroy()). Leaking task handles leaks resources. - Check
xTaskCancel()return value before releasing the arg.xErrno_Okmeans the task will not execute — safe to free.xErrno_Busymeans it's already running or done — you mustxTaskWait()first. - Set
queue_capfor backpressure. Without a cap, unbounded submission can exhaust memory. A bounded queue lets you detect overload via NULL returns fromxTaskSubmit(). - Don't destroy the global group.
xTaskGroupGlobal()is managed internally and destroyed atatexit(). Passing it toxTaskGroupDestroy()is undefined behavior. - Use
xTaskGroupWait()for barriers, not busy-polling. It uses a dedicated condition variable and blocks efficiently.
Comparison with Other Libraries
| Feature | xbase task.h | pthread | C11 threads | GCD (libdispatch) |
|---|---|---|---|---|
| Abstraction | Task (submit/wait) | Thread (create/join) | Thread (create/join) | Block (dispatch_async) |
| Thread Management | Automatic (lazy spawn) | Manual | Manual | Automatic |
| Queue | Built-in FIFO with cap | N/A | N/A | Built-in (serial/concurrent) |
| Result Retrieval | xTaskWait(t, &result) | pthread_join(t, &result) | thrd_join(t, &result) | Completion handler |
| Group Wait | xTaskGroupWait() | Manual barrier | Manual barrier | dispatch_group_wait() |
| Backpressure | queue_cap → NULL on full | N/A | N/A | N/A (unbounded) |
| Global Pool | xTaskGroupGlobal() | N/A | N/A | dispatch_get_global_queue() |
| Platform | macOS + Linux | POSIX | C11 | macOS + Linux (via libdispatch) |
| Dependencies | pthread | OS | OS | OS / libdispatch |
Key Differentiator: xbase's task model provides a simple, portable thread pool with lazy spawning and explicit backpressure — features that require significant boilerplate with raw pthreads. Unlike GCD, it gives you direct control over thread count and queue capacity.
Implementation Details
Internal Structure
struct xTask_ {
xTaskFunc fn; // User function
void *arg; // User argument
xNote note; // 4-byte one-shot completion notification
void *result; // Return value of fn
struct xTaskGroup_ *group; // Back-pointer to owning group
struct xTask_ *next; // Intrusive queue linkage (task queue + TLS freelist)
xMpsc done_link; // Lock-free done-list linkage (xMpsc)
atomic_int state; // QUEUED → RUNNING/CANCELLED → DONE (CAS-based cancel)
};
// sizeof(xTask_) ≈ 48 bytes (down from ~136 bytes with mutex+cond)
struct xTaskGroup_ {
pthread_t *workers; // Dynamic array of worker threads
size_t max_threads; // Upper bound (SIZE_MAX if unlimited)
size_t nthreads; // Currently spawned threads
pthread_mutex_t qlock; // Protects the task queue
pthread_cond_t qcond; // Wakes idle workers
struct xTask_ *qhead, *qtail; // FIFO task queue
size_t qsize, qcap; // Current size and capacity
xMpsc *done_head; // Lock-free MPSC done queue (head)
xMpsc *done_tail; // Lock-free MPSC done queue (tail)
size_t idle; // Number of idle workers
atomic_size_t pending; // Submitted - finished
atomic_size_t done_count; // Tasks completed
pthread_cond_t wcond; // Dedicated cond for xTaskGroupWait()
bool shutdown; // Shutdown flag
};
TLS Freelist
In the common event-loop offload path, xTaskSubmit() (alloc) and xTaskWait() (free) happen on the same thread. A per-thread freelist eliminates malloc/free overhead entirely — zero locks, zero atomics. The task->next pointer is reused as the freelist link (zero extra memory). A per-thread cap of 64 prevents unbounded caching.
static __thread struct {
struct xTask_ *head;
size_t count;
} tl_free = {NULL, 0};
Worker Loop
Each worker thread runs worker_loop():
- Acquire lock and increment
idlecount. - Wait on
qcondwhile the queue is empty and not shutting down. - Dequeue one task, decrement
idle. - CAS state QUEUED → RUNNING — if the CAS fails (task was cancelled), skip execution.
- Execute
task->fn(task->arg)(only if step 4 succeeded). - Push to done queue via
xMpscPush()(lock-free, wait-free for producers). - Signal completion via
xNoteSignal()(atomic store + kernel wake). - Update counters — decrement
pending, signalwcondif all tasks are done.
Task Submission Flow
flowchart TD
SUBMIT["xTaskSubmit(group, fn, arg)"]
CHECK_CAP{"Queue full?"}
ENQUEUE["Enqueue task"]
CHECK_IDLE{"Idle workers > 0?"}
SIGNAL["Signal qcond"]
CHECK_MAX{"nthreads < max?"}
SPAWN["Spawn new worker"]
DONE["Return task handle"]
FAIL["Return NULL"]
SUBMIT --> CHECK_CAP
CHECK_CAP -->|Yes| FAIL
CHECK_CAP -->|No| ENQUEUE
ENQUEUE --> CHECK_IDLE
CHECK_IDLE -->|Yes| SIGNAL
CHECK_IDLE -->|No| CHECK_MAX
CHECK_MAX -->|Yes| SPAWN
CHECK_MAX -->|No| DONE
SPAWN --> SIGNAL
SIGNAL --> DONE
style SUBMIT fill:#4a90d9,color:#fff
style FAIL fill:#e74c3c,color:#fff
style DONE fill:#50b86c,color:#fff
Separate Wait Conditions
The implementation uses two separate condition variables:
qcond— Wakes idle workers when a new task arrives.wcond— WakesxTaskGroupWait()callers when all tasks complete.
Using a single condition variable caused lost wakeups: pthread_cond_signal() could wake an idle worker instead of the GroupWait caller, leaving it blocked forever.
Global Task Group
xTaskGroupGlobal() uses pthread_once for thread-safe lazy initialization. The group is registered with atexit() for automatic cleanup. It uses default configuration (unlimited threads, no queue cap).
memory.h — Reference-Counted Memory Management
Introduction
memory.h provides a vtable-driven, reference-counted memory management system for C. It enables object lifecycle management (construction, destruction, retain, release, copy, move) through a virtual table pattern, bringing RAII-like semantics to pure C. The XMALLOC(T) macro allocates an object with an embedded header that tracks the reference count and vtable pointer.
Design Philosophy
-
vtable-Driven Lifecycle — Each object type defines a static
xVTablewith optional function pointers forctor,dtor,retain,release,copy, andmove. This decouples lifecycle logic from the allocation mechanism, similar to C++ virtual destructors or Objective-C's class methods. -
Hidden Header Pattern — A
Headerstruct is prepended to every allocation, storing the type name (for debugging), size, reference count, and vtable pointer. The user receives a pointer past the header, so the header is invisible to normal usage. -
Atomic Reference Counting —
xRetain()andxRelease()use atomic operations (__ATOMIC_SEQ_CST) to safely manage reference counts across threads. When the count reaches zero, the destructor is called and memory is freed. -
Macro Convenience —
XMALLOC(T)andXMALLOCEX(T, sz)macros generate the correctxAlloc()call with the type name string, size, and vtable pointer, reducing boilerplate.
Architecture
graph TD
MACRO["XMALLOC(T) / XMALLOCEX(T, sz)"]
ALLOC["xAlloc(name, size, count, vtab)"]
HEADER["Header + Object"]
RETAIN["xRetain(ptr)<br/>atomic refs++"]
RELEASE["xRelease(ptr)<br/>atomic refs--"]
FREE["xFree(ptr)<br/>dtor + free"]
COPY["xCopy(ptr, other)"]
MOVE["xMove(ptr, other)"]
MACRO --> ALLOC
ALLOC --> HEADER
HEADER --> RETAIN
HEADER --> RELEASE
RELEASE -->|"refs == 0"| FREE
HEADER --> COPY
HEADER --> MOVE
style MACRO fill:#4a90d9,color:#fff
style RELEASE fill:#e74c3c,color:#fff
style FREE fill:#e74c3c,color:#fff
API Reference
Macros
| Macro | Expansion | Description |
|---|---|---|
XDEF_VTABLE(T) | static xVTable TVTable = | Define a static vtable for type T |
XDEF_CTOR(T) | static void TCtor(T *self) | Define a constructor for type T |
XDEF_DTOR(T) | static void TDtor(T *self) | Define a destructor for type T |
XMALLOC(T) | (T *)xAlloc("T", sizeof(T), 1, &TVTable) | Allocate one T with vtable |
XMALLOCEX(T, sz) | (T *)xAlloc("T", sizeof(T) + sz, 1, &TVTable) | Allocate T + extra bytes |
Types
| Type | Description |
|---|---|
xVTable | Struct with function pointers: ctor, dtor, retain, release, copy, move |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xAlloc | void *xAlloc(const char *name, size_t size, size_t count, xVTable *vtab) | Allocate object(s) with header and call ctor. | Not thread-safe |
xFree | void xFree(void *ptr) | Call dtor and free. Ignores NULL. | Not thread-safe |
xRetain | void xRetain(void *ptr) | Increment reference count atomically. Calls vtab->retain if set. | Thread-safe |
xRelease | void xRelease(void *ptr) | Decrement reference count atomically. Calls vtab->release then xFree when refs reach 0. | Thread-safe |
xCopy | void xCopy(void *ptr, void *other) | Call vtab->copy if set. | Not thread-safe |
xMove | void xMove(void *ptr, void *other) | Call vtab->move if set. | Not thread-safe |
Usage Examples
Basic Object with Constructor/Destructor
#include <stdio.h>
#include <string.h>
#include <x/base/memory.h>
typedef struct Connection Connection;
struct Connection {
int fd;
char host[256];
};
XDEF_CTOR(Connection) {
self->fd = -1;
memset(self->host, 0, sizeof(self->host));
printf("Connection created\n");
}
XDEF_DTOR(Connection) {
if (self->fd >= 0) {
// close(self->fd);
printf("Connection closed (fd=%d)\n", self->fd);
}
}
XDEF_VTABLE(Connection) {
.ctor = ConnectionCtor,
.dtor = ConnectionDtor,
};
int main(void) {
Connection *conn = XMALLOC(Connection);
conn->fd = 42;
strcpy(conn->host, "example.com");
xRetain(conn); // refs = 2
xRelease(conn); // refs = 1
xRelease(conn); // refs = 0 → dtor called → freed
return 0;
}
Flexible Array Member with XMALLOCEX
#include <stdio.h>
#include <string.h>
#include <x/base/memory.h>
typedef struct Buffer Buffer;
struct Buffer {
size_t len;
char data[]; // flexible array member
};
XDEF_CTOR(Buffer) { self->len = 0; }
XDEF_DTOR(Buffer) { /* nothing to clean up */ }
XDEF_VTABLE(Buffer) { .ctor = BufferCtor, .dtor = BufferDtor };
int main(void) {
// Allocate Buffer + 1024 extra bytes for data[]
Buffer *buf = XMALLOCEX(Buffer, 1024);
memcpy(buf->data, "Hello, libx!", 12);
buf->len = 12;
printf("Buffer: %.*s\n", (int)buf->len, buf->data);
xRelease(buf); // refs 1 → 0 → freed
return 0;
}
Use Cases
-
Shared Ownership — Multiple components hold references to the same object (e.g., a connection shared between a reader and a writer).
xRetain/xReleaseensures the object is freed only when the last reference is dropped. -
Plugin/Extension Objects — Define vtables for different object types that share a common interface. The vtable pattern enables polymorphic behavior in C.
-
Debug-Friendly Allocation — The
namefield in the header enables allocation tracking and leak detection by type name.
Best Practices
- Always pair
xRetainwithxRelease. Every retain must have a corresponding release, or you'll leak memory. - Use
XMALLOCinstead of rawxAlloc. The macro handles type name, size, and vtable automatically. - Set unused vtable fields to NULL. The implementation checks for NULL before calling each vtable function.
- Don't mix with
free(). Objects allocated withxAllochave a hidden header. Callingfree()directly on the user pointer corrupts the heap. - Use
XMALLOCEXfor flexible array members. It adds extra bytes after the struct for variable-length data.
Comparison with Other Libraries
| Feature | xbase memory.h | C++ RAII | Objective-C ARC | GLib GObject |
|---|---|---|---|---|
| Mechanism | vtable + atomic refcount | Destructor + smart pointers | Compiler-inserted retain/release | GType + refcount |
| Automation | Manual retain/release | Automatic (scope-based) | Automatic (compiler) | Manual ref/unref |
| Thread Safety | Atomic refcount | shared_ptr is atomic | Atomic | Atomic |
| Polymorphism | vtable function pointers | Virtual functions | Method dispatch | Signal/slot + vtable |
| Overhead | 1 header per object (~32 bytes) | 0 (stack) or control block | 1 isa pointer + refcount | Large (GTypeInstance) |
| Flexible Arrays | XMALLOCEX(T, sz) | std::vector | NSMutableData | GArray |
| Debug Info | Type name in header | RTTI | Class name | GType name |
| Language | C99 | C++ | Objective-C | C (with macros) |
Key Differentiator: xbase's memory system brings reference-counted lifecycle management to C with minimal overhead — just a 32-byte header per object. The vtable pattern provides extensibility (custom ctor/dtor/copy/move) without requiring a complex type system like GObject.
Benchmark
Environment: Apple M3 Pro, 36 GB RAM, macOS 26.4, Release build (
-O2). Source:xbase/memory_bench.cpp
| Benchmark | Size (bytes) | Time (ns) | CPU (ns) | Iterations |
|---|---|---|---|---|
BM_Memory_XAlloc | 16 | 23.3 | 23.3 | 29,809,940 |
BM_Memory_XAlloc | 64 | 21.1 | 21.1 | 32,551,024 |
BM_Memory_XAlloc | 256 | 22.4 | 22.4 | 31,207,508 |
BM_Memory_XAlloc | 1,024 | 20.1 | 20.1 | 34,024,352 |
BM_Memory_XAlloc | 4,096 | 24.2 | 24.2 | 29,002,681 |
BM_Memory_Malloc | 16 | 17.5 | 17.5 | 39,883,995 |
BM_Memory_Malloc | 64 | 18.7 | 18.7 | 37,576,831 |
BM_Memory_Malloc | 256 | 19.0 | 19.0 | 34,505,536 |
BM_Memory_Malloc | 1,024 | 23.0 | 23.0 | 30,557,144 |
BM_Memory_Malloc | 4,096 | 17.7 | 17.7 | 39,849,483 |
BM_Memory_RetainRelease | — | 3.90 | 3.90 | 183,068,277 |
Key Observations:
- xAlloc vs malloc overhead is only ~3–5ns across all sizes. The extra cost covers header initialization, vtable setup, and constructor invocation — negligible for most workloads.
- Retain/Release cycle takes ~3.9ns, dominated by the atomic increment/decrement. This is fast enough for hot-path reference counting.
- Allocation time is nearly constant across sizes (16B–4KB), confirming that the overhead is in the header management, not the underlying
malloc.
Implementation Details
Memory Layout
graph LR
subgraph "malloc'd block"
HDR["Header<br/>name | size | refs | vtab"]
OBJ["User Object<br/>(sizeof(T) bytes)"]
EXTRA["Extra bytes<br/>(XMALLOCEX only)"]
end
PTR["xAlloc() returns →"] --> OBJ
style HDR fill:#f5a623,color:#fff
style OBJ fill:#4a90d9,color:#fff
style EXTRA fill:#50b86c,color:#fff
The actual memory layout:
┌──────────────────────────────────────────────────────┐
│ Header (hidden) │
│ const char *name — type name string (e.g. "Foo") │
│ size_t size — sizeof(T) │
│ size_t refs — reference count (starts at 1) │
│ xVTable *vtab — pointer to static vtable │
├──────────────────────────────────────────────────────┤
│ User Object (returned pointer) │
│ T fields... │
│ [optional extra bytes from XMALLOCEX] │
└──────────────────────────────────────────────────────┘
XMALLOC / XMALLOCEX Macro Expansion
// Given:
typedef struct Foo Foo;
struct Foo { int x; char buf[]; };
XDEF_VTABLE(Foo) { .ctor = FooCtor, .dtor = FooDtor };
XDEF_CTOR(Foo) { self->x = 0; }
XDEF_DTOR(Foo) { /* cleanup */ }
// XMALLOC(Foo) expands to:
(Foo *)xAlloc("Foo", sizeof(Foo), 1, &FooVTable)
// XMALLOCEX(Foo, 128) expands to:
(Foo *)xAlloc("Foo", sizeof(Foo) + 128, 1, &FooVTable)
Reference Count Lifecycle
sequenceDiagram
participant App
participant Alloc as xAlloc
participant Header
participant VTable
App->>Alloc: XMALLOC(Foo)
Alloc->>Header: malloc(sizeof(Header) + sizeof(Foo))
Alloc->>Header: refs = 1
Alloc->>VTable: vtab->ctor(ptr)
Alloc-->>App: Foo *ptr
App->>Header: xRetain(ptr) → refs = 2
App->>Header: xRelease(ptr) → refs = 1
App->>Header: xRelease(ptr) → refs = 0
Header->>VTable: vtab->release(ptr)
Header->>VTable: vtab->dtor(ptr)
Header->>Header: free(hdr)
Thread Safety
xRetain()andxRelease()are thread-safe — they usexAtomicAdd/xAtomicSubwith sequential consistency ordering.xAlloc(),xFree(),xCopy(), andxMove()are not thread-safe — they should be called from a single owner or with external synchronization.
slab.h — Fixed-Size Object Pool (Slab Allocator)
Introduction
slab.h provides a fixed-size object pool that carves large OS-backed chunks into equally-sized slots and hands them out via an intrusive freelist. It is designed to replace the many small calloc(1, sizeof(T)) / free() call sites scattered throughout xbase where objects are allocated and freed at very high frequency — event sources, timer entries, tree nodes, hash entries, task structs, and so on.
Two variants are provided behind a uniform API shape:
xSlab— single-threaded, zero synchronisation overhead. Use this when the pool is owned by a single thread (e.g. a map backend or an event loop's internal bookkeeping).xSlabMt— multi-threaded. A plain LIFO freelist guarded by a short-held internal spinlock. Use this when allocations and frees may come from different threads (e.g. cross-thread task submission).
Both variants never return individual slots to the OS. Memory is released only when the pool itself is destroyed (or, for xSlab, explicitly reclaimed in bulk via xSlabReset).
Design Philosophy
-
Fixed Slot Size — A pool is parameterised by
(obj_size, obj_align)at create time. Every slot has identical layout, which lets allocation collapse to "pop the head of an intrusive freelist" and deallocation to "push onto that freelist" — both O(1) with zero metadata search. -
Chunk-Backed Growth — When the freelist is empty the pool asks the OS for a contiguous chunk (default 64 KiB, configurable), slices it into slots, and links them into the freelist. Chunks are acquired through the platform's native anonymous mapping facility (
mmapon POSIX,VirtualAllocon Windows) and fall back tomallocwhere neither is available. -
Uninitialised Memory — Slots are returned uninitialised; callers that previously relied on
calloc's zeroing must callmemsetexplicitly. This removes a per-alloc cost that is often wasted when the caller overwrites the fields immediately. -
Configurable Alignment — The default alignment is 16 bytes, which satisfies the requirements of SIMD and common atomic instructions. Callers with stricter requirements (e.g. cache-line alignment for false-sharing mitigation) can pass a larger power-of-two.
-
Spinlock-Guarded Multi-Thread Path —
xSlabMtprotects its freelist with a single short-held spinlock. An earlier lock-free Treiber-stack implementation had an ABA use-after-free hazard: user writes into the handed-out slot could overlap with a preempted popper's stalenextsnapshot, so the CAS could publish a garbage pointer as the new head. Replacing the Treiber stack with a spinlock eliminates the hazard at the cost of mild contention above four threads — a trade-off that is invisible to xbase's actual consumers (timer/task submission) and documented honestly in the benchmark section. -
No Header Per Slot — Unlike general-purpose allocators, the pool stores no per-slot metadata (no size, no cookie). The only per-slot state is the intrusive freelist pointer, which occupies the slot itself while it is free.
Architecture
graph TD
CREATE["xSlabCreate(obj_size, obj_align, chunk_bytes)"]
POOL["xSlab pool<br/>freelist head + chunk list"]
ALLOC["xSlabAlloc(pool)<br/>pop freelist head"]
FREE["xSlabFree(pool, p)<br/>push onto freelist"]
RESET["xSlabReset(pool)<br/>rebuild freelist from chunks"]
DESTROY["xSlabDestroy(pool)<br/>munmap all chunks"]
GROW["grow():<br/>mmap(chunk_bytes)<br/>slice into slots<br/>link into freelist"]
CREATE --> POOL
POOL --> ALLOC
POOL --> FREE
POOL --> RESET
POOL --> DESTROY
ALLOC -.->|"freelist empty"| GROW
GROW --> POOL
style POOL fill:#4a90d9,color:#fff
style ALLOC fill:#50b86c,color:#fff
style FREE fill:#50b86c,color:#fff
style GROW fill:#f5a623,color:#fff
style DESTROY fill:#e74c3c,color:#fff
API Reference
Constants
| Macro | Value | Description |
|---|---|---|
XSLAB_DEFAULT_ALIGN | 16 | Default slot alignment when obj_align == 0 |
XSLAB_DEFAULT_CHUNK_BYTES | 64 * 1024 | Default chunk size when chunk_bytes == 0 |
Types
| Type | Description |
|---|---|
xSlab | Opaque handle to a single-threaded pool |
xSlabMt | Opaque handle to a multi-threaded pool |
Functions — xSlab (single-threaded)
| Function | Signature | Description |
|---|---|---|
xSlabCreate | xSlab *xSlabCreate(size_t obj_size, size_t obj_align, size_t chunk_bytes) | Create a pool. 0 selects defaults for align/chunk. Returns NULL on invalid args or OOM. |
xSlabDestroy | void xSlabDestroy(xSlab *s) | Release all chunks. All outstanding slots become invalid. NULL is a no-op. |
xSlabAlloc | void *xSlabAlloc(xSlab *s) | Return one uninitialised slot of obj_size bytes at obj_align. NULL on OOM. |
xSlabFree | void xSlabFree(xSlab *s, void *p) | Return a slot to the pool. NULL is a no-op. The slot must not be touched afterward. |
xSlabReset | void xSlabReset(xSlab *s) | Bulk-reclaim every slot without freeing chunks. Caller must guarantee no slot is live. |
xSlabInUse | size_t xSlabInUse(const xSlab *s) | Number of slots currently handed out. |
xSlabSlotSize | size_t xSlabSlotSize(const xSlab *s) | Configured slot size (after alignment rounding). |
Functions — xSlabMt (multi-threaded)
| Function | Signature | Description |
|---|---|---|
xSlabMtCreate | xSlabMt *xSlabMtCreate(size_t obj_size, size_t obj_align, size_t chunk_bytes) | Create a thread-safe pool. Same parameter semantics as xSlabCreate. |
xSlabMtDestroy | void xSlabMtDestroy(xSlabMt *s) | Release all chunks. Caller must externally quiesce all users first. |
xSlabMtAlloc | void *xSlabMtAlloc(xSlabMt *s) | Thread-safe alloc. Lock-free fast path (CAS on freelist head). |
xSlabMtFree | void xSlabMtFree(xSlabMt *s, void *p) | Thread-safe free. Lock-free fast path. |
xSlabMtSlotSize | size_t xSlabMtSlotSize(const xSlabMt *s) | Configured slot size. |
Usage Examples
Single-threaded: tree node pool
#include <stdlib.h>
#include <string.h>
#include <x/base/slab.h>
typedef struct Node Node;
struct Node {
Node *left, *right;
int key;
void *value;
};
int main(void) {
// One slot per Node, default 16-byte alignment, default 64 KiB chunks.
xSlab *pool = xSlabCreate(sizeof(Node), 0, 0);
Node *root = xSlabAlloc(pool);
memset(root, 0, sizeof(*root)); // slab does not zero
root->key = 42;
// ... manipulate tree, allocate more nodes, free when removing ...
xSlabFree(pool, root);
xSlabDestroy(pool); // releases every chunk at once
return 0;
}
Multi-threaded: cross-thread task structs
#include <x/base/slab.h>
static xSlabMt *g_task_pool;
void task_pool_init(void) {
g_task_pool = xSlabMtCreate(sizeof(struct Task), 0, 0);
}
struct Task *task_alloc(void) {
struct Task *t = xSlabMtAlloc(g_task_pool);
memset(t, 0, sizeof(*t));
return t;
}
void task_free(struct Task *t) {
xSlabMtFree(g_task_pool, t); // safe from any thread
}
void task_pool_shutdown(void) {
xSlabMtDestroy(g_task_pool); // caller must have quiesced all workers
}
Bulk reclaim with xSlabReset
// Event loop shuts down — every event source is about to be destroyed.
// Rather than freeing sources one by one, reset the pool in O(chunks):
xSlabReset(loop->source_pool);
// Pool keeps its chunks, ready to be reused when the loop restarts.
Use Cases
-
High-Frequency Small Allocations — Timer entries, event sources, map nodes, task structs. Anything that used to be a
calloc(1, sizeof(T))in a hot path is a candidate. -
Uniform-Size Containers — A hash/tree map with fixed-size nodes is a perfect fit: every node has the same layout, and deletions recycle through the freelist immediately.
-
Phase-Scoped Arenas via
xSlabReset— When an entire subsystem is torn down,xSlabResetreturns every slot at once without any per-slot bookkeeping. Combined with non-destructive teardown, it enables arena-style lifetimes in C. -
Cross-Thread Object Recycling —
xSlabMtis the right tool when producers on one thread allocate objects that consumers on another thread eventually free. The short-held spinlock avoids the general-purpose allocator's size-class lookup and the bookkeeping overhead of per-thread caches.
Best Practices
- Pick the right variant. If a pool is touched by only one thread, use
xSlab— its fast path is a plain load/store with no synchronisation. Reach forxSlabMtonly when you actually cross threads. - Zero explicitly if you need zeroing. Slots come back uninitialised. Do
memset(p, 0, xSlabSlotSize(pool))if your code previously depended oncalloc. - Match each slot size to one type. Don't mix differently-sized objects in the same pool; create separate pools per type. Slot size is fixed at create time.
- Don't mix with
free(). Slots are carved from a chunk; they are not independently freeable. Always usexSlabFree/xSlabMtFree. - Destroy invalidates everything. After
xSlabDestroy, every slot the pool ever handed out is dangling. Make sure lifetime containment is obvious at the call site. - Reset is a footgun.
xSlabResetdoes not run any destructor — only call it when you are certain every slot is either already cleaned up or safely discardable.
Comparison with Other Approaches
| Feature | xSlab / xSlabMt | malloc / free | Thread-local freelist | C++ std::pmr::pool_resource |
|---|---|---|---|---|
| Slot size | Fixed per pool | Arbitrary | Fixed per freelist | Fixed per pool |
| Alloc fast path | Load + store (ST) / spinlock + load-store (MT) | Size-class lookup + lock | Load + store, but only same thread | Size-class lookup |
| Cross-thread free | xSlabMt supports it | Yes (slow path) | No (must return to origin) | Depends on upstream |
| Per-slot header | None | Typically 8–16 bytes | None | Implementation-defined |
| OS syscall rate | One mmap per chunk (64 KiB) | Many mmap/sbrk depending on impl | None (built on malloc) | Depends on upstream |
| Bulk reclaim | xSlabReset (O(chunks)) | No | No | release() |
| Returns memory to OS | Only on Destroy | Depends on impl | No | On release() |
Key Differentiator: xSlab trades generality (fixed slot size, no per-slot size/type info) for a predictable, extremely cheap fast path and a single munmap per chunk at shutdown. For containers whose nodes are uniform, that trade is almost always worth it.
Benchmark
Environment: Apple Mac15,7 (12 cores), 36 GB RAM, macOS 26.x, Release build (
-O2). Each result is the median of 3 repetitions (--benchmark_min_time=1.0s --benchmark_repetitions=3 --benchmark_report_aggregates_only=true). Source:xbase/slab_bench.cpp
Single-Threaded Alloc + Free
| Benchmark | Time (ns) | Notes |
|---|---|---|
BM_Slab_AllocFree | 2.58 | xSlabAlloc + xSlabFree, 32-byte slots |
BM_Malloc_AllocFree | 18.9 | malloc + free, 32 bytes |
BM_Calloc_AllocFree | 16.9 | calloc + free, 32 bytes |
Single-threaded allocation is ~7.3× faster than malloc and ~6.5× faster than calloc. The slab fast path is a single load + store on the freelist head; malloc must traverse its size-class table and take at least one internal lock even on macOS.
Batched Alloc + Free (Single-Threaded)
| Benchmark | Batch | Time (ns) | Slab vs malloc |
|---|---|---|---|
BM_Slab_Batch | 16 | 37.9 | |
BM_Malloc_Batch | 16 | 287 | slab 7.6× faster |
BM_Slab_Batch | 256 | 590 | |
BM_Malloc_Batch | 256 | 4,409 | slab 7.5× faster |
BM_Slab_Batch | 4,096 | 15,236 | |
BM_Malloc_Batch | 4,096 | 73,612 | slab 4.8× faster |
The gap narrows somewhat at 4K slots because the first chunk (64 KiB / 32 B = 2,048 slots) fills up and a second chunk must be carved — a one-shot mmap cost amortised across the remaining slots. Steady-state performance still matches the single-op numbers above.
Multi-Threaded Alloc + Free
| Threads | xSlabMt (ns) | malloc (ns) | Winner |
|---|---|---|---|
| 1 | 9.79 | 18.8 | slab 1.9× faster |
| 2 | ~80 | 91.3 | roughly tied |
| 4 | 540 | 476 | malloc 1.1× faster |
| 8 | ~1,100 | 46.4 | macOS malloc much faster |
The crossover above four threads is real and worth understanding:
xSlabMtserialises allocations through a single spinlock. With many threads doing nothing but alloc/free in a tight loop the critical section becomes a contention hotspot.- macOS's
malloc(libmalloc's nano zone) maintains per-thread caches that are essentially uncontended up to the small-allocation size class, so 8 threads rarely touch any shared state.
The earlier PR shipped a lock-free Treiber-stack variant that benched a bit faster at four threads but had an ABA hazard around the user-writable first word of a popped slot. The hazard is fundamental to a word-width CAS without a tag, and the spinlock is a clean, portable fix. In practice xSlabMt's usage inside xbase (task/timer/event bookkeeping) allocates at a rate where the lock is rarely contended — timer/task benchmarks elsewhere in these docs still show ~2× gains over the previous malloc/TLS-freelist implementations. If you have a workload with eight or more threads each churning small allocations back-to-back with no other work, put a per-thread cache in front of xSlabMt.
Key Observations:
- Single-threaded allocation is 7× faster than
malloc. This is the primary win; it applies to every map backend, timer heap node, and event-loop bookkeeping struct. - Multi-threaded allocation is faster than
mallocup to ~2 threads and within the same order of magnitude at four. This matches the concurrency envelope of xTask/xTimer under typical xbase workloads, where the downstream wins (SubmitCancel ~2× faster, FanOut throughput ~2× higher) are driven by eliminatingcallocin the submission path rather than by the raw allocator being the fastest at high thread counts. - Zero-init is not free.
BM_Calloc_AllocFreeis ~10% faster thanmallocon macOS because libmalloc short-circuits zeroing for freshly-mmaped pages. For pre-used memory callers should stillmemset. - Bulk
xSlabResetis O(chunks) and can reclaim 64 KiB worth of slots per chunk in a single loop pass — far cheaper than individual frees when tearing a subsystem down.
Integration Status
Within xbase, the following modules have been migrated from calloc to the slab allocator:
| Module | Variant | Slot | Rationale |
|---|---|---|---|
map.c (hash + tree backends) | xSlab | hash entry / tree node | map operations are single-threaded; nodes are uniform-size. |
timer.c | xSlabMt | xTimerTask_ | timer submission is cross-thread; push-mode hands the entry to the task pool. |
task.c | xSlabMt | xTask_ | task structs are freed on worker threads after execution. |
See the respective module documents for benchmarks of the integrated paths.
Implementation Details
Memory Layout
Each chunk is a single OS-backed mapping of at least chunk_bytes rounded up to hold an integral number of slots. Slots are laid out back-to-back at the configured alignment; the chunk header itself is embedded at the start of the mapping and linked into the pool's chunk list for later release.
chunk (64 KiB default)
┌──────────────────────────────────────────────────────────────┐
│ chunk header (next pointer, size) │
├───────┬───────┬───────┬───────┬───────┬───────┬─────┬────────┤
│ slot0 │ slot1 │ slot2 │ slot3 │ slot4 │ ... │ ... │ slotN │
└───┬───┴───┬───┴───┬───┴───┬───┴───┬───┴───────┴─────┴────────┘
│ │ │ │ │
└───────┴───────┴───────┴───────┘ (free slots chained via
first word of each slot)
pool.free_head ─► slotK ─► slotJ ─► ... ─► NULL
A free slot's first word is the pointer to the next free slot (intrusive list). Once handed out, that same word becomes part of the caller's object and can be used freely; on xSlabFree the pool overwrites it again to stitch the slot back into the freelist.
Fast-Path Operations
// xSlabAlloc — single-threaded
if (pool->free_head == NULL) grow(pool);
slot = pool->free_head;
pool->free_head = *(void **)slot;
return slot;
// xSlabFree — single-threaded
*(void **)slot = pool->free_head;
pool->free_head = slot;
xSlabMt performs the same two-instruction sequence inside a spinlock:
// xSlabMt — multi-threaded
spin_lock(&pool->lock);
if (pool->free_head == NULL) grow(pool); // under the same lock
slot = pool->free_head;
pool->free_head = *(void **)slot;
spin_unlock(&pool->lock);
return slot;
The lock also covers grow() (OS mapping + freelist seeding) so only one thread can call into the OS at a time. The spinlock uses xAtomicCasWeak to acquire and xAtomicStore(release) to release.
Lifecycle
sequenceDiagram
participant App
participant Pool as xSlab
participant OS
App->>Pool: xSlabCreate(sizeof(T), 0, 0)
Note over Pool: free_head = NULL, no chunks
App->>Pool: xSlabAlloc()
Pool->>OS: mmap(64 KiB)
OS-->>Pool: chunk base
Note over Pool: slice into slots,<br/>link into freelist
Pool-->>App: slot pointer
App->>Pool: xSlabFree(slot)
Note over Pool: push slot onto<br/>freelist head
App->>Pool: xSlabAlloc() × many
Note over Pool: pops reuse slots<br/>without touching OS
App->>Pool: xSlabDestroy()
Pool->>OS: munmap(each chunk)
Thread Safety
| Function | xSlab | xSlabMt |
|---|---|---|
Create / Destroy | Not thread-safe | Not thread-safe (caller must quiesce) |
Alloc / Free | Not thread-safe | Thread-safe (spinlock-guarded) |
Reset | Not thread-safe | N/A — xSlabMt has no bulk reclaim |
InUse / SlotSize | Not thread-safe read | SlotSize is a constant read, safe after create |
arena.h — Fixed-Capacity Bump Allocator
Introduction
arena.h provides a simple bump allocator: allocate a block of memory up front and hand out variable-sized slices by bumping a pointer forward. Memory is never individually freed — it is reclaimed only by destroying the arena or calling xArenaReset(). This makes it ideal for phase-scoped allocations where every object shares the same lifetime.
Typical use cases include:
- Parse trees — Allocate every node, string, and metadata from a single arena. One
xArenaDestroy()cleans up the entire tree. - Request-scoped data — In an HTTP server, allocate per-request state (headers, body buffers, parsed JSON) from a per-request arena. Reset between requests.
- Temporary scratch buffers — Allocate composite structures during a computation and discard them all at once when the computation finishes.
The arena never grows beyond its initial capacity. xArenaAlloc() returns NULL when full, forcing the caller to either use a larger arena or fall back to the general-purpose allocator — a deliberate design choice that keeps the fast path a single pointer bump with no churn.
Design Philosophy
-
Bump, Don't Bookkeep — Allocation is a single aligned pointer bump. No freelists, no size-class lookups, no per-slot headers. The trade-off: individual frees are impossible; all memory must be reclaimed at once.
-
Fixed Capacity, Explicit Failure — The arena never grows.
xArenaAlloc()returns NULL when the remaining space (after alignment) is insufficient. Callers must handle this case explicitly, which prevents silent performance degradation from unbounded growth. -
No Destructor Tracking — The arena does not know what objects were allocated from it. Callers are responsible for calling destructors / cleanup functions before
xArenaDestroy()orxArenaReset(). This keeps the arena minimal and avoids the overhead of a destructor list. -
O(1) Ownership Test —
xArenaOwns()answers "was this pointer allocated from this arena?" in a single pointer-range comparison. Useful for assertions, debugging, and cases where the caller needs to verify arena membership before writing to a shared buffer. -
Uninitialised Memory —
xArenaAlloc()returns uninitialised memory (likemalloc). Callers that need zeroed memory must callmemsetexplicitly. This avoids wasted writes when the caller intends to overwrite every byte immediately. -
Single Buffer, No Chunk Chaining — Unlike
xSlab(which grows by acquiring additional OS-backed chunks), xArena is backed by a single contiguousmalloc-ed buffer. The trade-off is a hard capacity ceiling, but the benefit is simpler code and predictable memory layout.
Architecture
graph TD
CREATE["xArenaCreate(capacity)"]
ALLOC["xArenaAlloc(size)<br/>align + bump"]
RESET["xArenaReset()<br/>pos = begin"]
DESTROY["xArenaDestroy()<br/>free(buffer)"]
FULL["return NULL"]
CREATE --> ALLOC
ALLOC -.->|"no space"| FULL
ALLOC --> RESET
RESET --> ALLOC
ALLOC --> DESTROY
style CREATE fill:#50b86c,color:#fff
style ALLOC fill:#4a90d9,color:#fff
style RESET fill:#f5a623,color:#fff
style DESTROY fill:#e74c3c,color:#fff
style FULL fill:#e74c3c,color:#fff
API Reference
Constants
| Macro | Value | Description |
|---|---|---|
XARENA_DEFAULT_ALIGN | 16 | Default alignment when align == 0 is passed to xArenaAllocAligned |
Types
| Type | Description |
|---|---|
xArena | Opaque handle to a fixed-capacity bump allocator |
Functions
| Function | Signature | Description |
|---|---|---|
xArenaCreate | xArena *xArenaCreate(size_t capacity) | Create an arena with capacity bytes pre-allocated. Returns NULL on OOM. |
xArenaDestroy | void xArenaDestroy(xArena *a) | Release the backing buffer and the arena handle. NULL is a no-op. All pointers become invalid. |
xArenaAlloc | void *xArenaAlloc(xArena *a, size_t size) | Bump-allocate size bytes with default (16-byte) alignment. Returns NULL if full. |
xArenaAllocAligned | void *xArenaAllocAligned(xArena *a, size_t size, size_t align) | Bump-allocate size bytes with explicit alignment. align must be a power of two. 0 selects default. |
xArenaCapacity | size_t xArenaCapacity(const xArena *a) | Total capacity in bytes. |
xArenaUsed | size_t xArenaUsed(const xArena *a) | Bytes consumed so far (includes alignment padding). |
xArenaRemaining | size_t xArenaRemaining(const xArena *a) | Bytes still available (before accounting for future alignment padding). |
xArenaOwns | int xArenaOwns(const xArena *a, const void *p) | Returns non-zero if p points within the arena's backing buffer. O(1). |
xArenaReset | void xArenaReset(xArena *a) | Reset bump pointer to start. Buffer stays allocated. All old pointers become dangling. |
Usage Examples
Basic: phase-scoped allocations
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <x/base/arena.h>
typedef struct Node {
char *name;
struct Node **children;
int child_count;
} Node;
Node *parse_tree(const char *input, xArena *arena) {
Node *n = xArenaAlloc(arena, sizeof(Node));
memset(n, 0, sizeof(*n));
n->name = xArenaAlloc(arena, 32);
strcpy(n->name, "root");
n->children = xArenaAlloc(arena, 4 * sizeof(Node *));
n->child_count = 1;
n->children[0] = parse_tree("child", arena);
return n;
}
int main(void) {
xArena *arena = xArenaCreate(4096);
if (!arena) return 1;
Node *root = parse_tree("input", arena);
printf("root: %s, children: %d\n", root->name, root->child_count);
// Everything — nodes, strings, arrays — freed with one call.
xArenaDestroy(arena);
return 0;
}
Request-scoped: reset between requests
void handle_requests(void) {
xArena *arena = xArenaCreate(64 * 1024); // 64 KiB per request
for (;;) {
// Wait for a request, parse headers, process body...
char *header_buf = xArenaAlloc(arena, 8192);
char *body_buf = xArenaAlloc(arena, 16384);
if (!header_buf || !body_buf) {
// Request too large — reject and reset.
xArenaReset(arena);
continue;
}
// ... handle request ...
// Reuse the same buffer for the next request.
xArenaReset(arena);
}
xArenaDestroy(arena);
}
Ownership check
void validate_buffer(xArena *arena, void *data) {
if (!xArenaOwns(arena, data)) {
// data was allocated elsewhere — don't write through it.
return;
}
memset(data, 0, 64); // safe: data is within our arena.
}
Use Cases
-
JSON / Config Parsing — Parse a JSON or TOML file into a DOM tree. Every node, string value, and key is allocated from the arena. One
xArenaDestroy()frees the entire tree — no recursivefree()loops. -
HTTP Request Handling — Allocate per-request state (parsed headers, URL path, body buffer, response builder) from a per-request arena.
xArenaReset()between requests avoids malloc/free churn under load. -
Compiler / Interpreter Front-Ends — Allocate the AST, symbol table, and type information from a single arena. When compilation finishes, the arena is destroyed — no need to walk the AST freeing each node.
-
Temporary Data Structures — Build a graph, a union-find structure, or a multi-level lookup table for a single computation, then discard it. The arena avoids the overhead of tracking and freeing each intermediate allocation.
-
Embedded / No-Allocator Environments — With a statically allocated buffer (e.g.,
static char buf[4096]), an arena can be wrapped to provide allocation without touching the system allocator. (Future enhancement: stack-based arena with inline storage.)
Best Practices
- Estimate capacity generously. The arena never grows. If you underestimate, you get NULL at runtime. Round up to the nearest power of two for alignment-friendliness. For parsing,
capacity = input_size * 2is a reasonable heuristic for a DOM tree. - Check for NULL. Every call to
xArenaAlloc()should be checked. A NULL return means the arena is full — either reset and retry, fall back tomalloc, or abort with an error. - Zero explicitly. Arena-allocated memory is uninitialised. Use
memset(p, 0, size)if you need zero-initialisation. - Destroy in reverse order. If you have multiple arenas, destroy the one that was created last first.
xArenaDestroy()invalidates all pointers from that arena, so any cross-arena references must be cleaned up first. - Don't mix with
free(). Arena-allocated pointers are slices of a singlemallocbuffer — they cannot be individually freed. Attemptingfree()on an arena pointer is undefined behaviour. - Reset is a footgun.
xArenaReset()does not run destructors. Only reset when you are certain no objects allocated from the arena are still in use, or when they are trivially discardable (plain data with no external resource ownership).
Comparison with xpp::Arena
The libxpp Arena<N> (C++) and xArena (C) share the same core concept — a bump allocator — but differ in implementation details due to language constraints:
| Feature | xpp::Arena<N> (C++) | xArena (C) |
|---|---|---|
| Storage | Inline for N ≤ 256, heap for N > 256 (compile-time) | Always heap-allocated (runtime) |
| Allocation | a.allocate(size, align) | xArenaAlloc(a, size) / xArenaAllocAligned(a, size, align) |
| Ownership | a.owns(p) — O(1) | xArenaOwns(a, p) — O(1) |
| Reset | a.reset() | xArenaReset(a) |
| Capacity query | a.total_capacity() / a.remaining() / a.used() | xArenaCapacity(a) / xArenaRemaining(a) / xArenaUsed(a) |
| Type-safe construction | a.make<T>(args...) — placement new | N/A — C has no constructors |
| Move semantics | Supported (move ctor / move assignment) | N/A — opaque handle, passed by pointer |
| Lifetime | RAII (destructor frees buffer) | Manual (xArenaDestroy) |
| Template / Macro | Compile-time N parameter | Runtime capacity parameter |
The key difference: xpp::Arena<N> exploits C++ templates to provide zero-heap-allocation inline storage for small arenas (≤ 256 bytes), making it suitable for stack-allocated temporary scratch arenas. xArena always heap-allocates its backing buffer via malloc, trading the zero-heap optimisation for a simpler, uniform API that does not require preprocessor tricks. A future XDEF_STACK_ARENA(name, capacity) macro could add inline-storage support to xArena without changing the existing API surface.
Implementation Details
Memory Layout
The arena is backed by a single contiguous buffer. The handle stores three pointers:
arena handle backing buffer (capacity bytes)
┌──────────┐ ┌────────────────────────────────────────┐
│ begin ───┼─────────────►│ ← pos (after creation / reset) │
│ pos ───┘ │ │
│ end ───┐ │ ← end │
└──────────┘ └────────────────────────────────────────┘
After two allocations (xArenaAlloc(a, 16) and xArenaAlloc(a, 32)):
┌────────────────────────────────────────────────┐
│ allocation 1 (16B) │ allocation 2 (32B) │ FREE │
│ ← pos now here ────┘ │ │
└──────────────────────────────────────────┘
The arena header itself is a separate heap allocation (3 pointers ≈ 24 bytes), keeping the backing buffer purely data — no metadata interleaved with user data.
Fast-Path Operations
// xArenaAllocAligned — O(1), one conditional branch
p = align_up(a->pos, align);
if (p + size > a->end) return NULL;
a->pos = p + size;
return p;
// xArenaOwns — O(1), two comparisons
return p >= a->begin && p < a->end;
// xArenaReset — O(1), one assignment
a->pos = a->begin;
There is no per-allocation metadata. The only state is pos — a single pointer update per allocation. This is the fastest possible allocator for phase-scoped memory.
Lifecycle
sequenceDiagram
participant App
participant Arena as xArena
App->>Arena: xArenaCreate(4096)
Note over Arena: malloc(4096) → buffer<br/>handle on heap (3 ptrs)
App->>Arena: xArenaAlloc(64)
Note over Arena: align_up<br/>pos += 64
App->>Arena: xArenaAlloc(128)
Note over Arena: align_up<br/>pos += 128
App->>Arena: xArenaReset()
Note over Arena: pos = begin<br/>(buffer stays allocated)
App->>Arena: xArenaAlloc(32) × many
Note over Arena: reuses the same buffer
App->>Arena: xArenaDestroy()
Note over Arena: free(buffer)<br/>free(handle)
Thread Safety
xArena is not thread-safe. It is designed to be owned by a single thread (typically the event-loop thread or a single parsing thread). If multiple threads need to allocate from the same arena, the caller must provide external synchronisation.
This is consistent with xArena's design goal: phase-scoped allocation is inherently single-threaded (a parse phase, a request handler, a single computation). The cost of atomics or locks is not worth paying for a use case that never crosses threads.
error.h — Unified Error Codes
Introduction
error.h defines a unified set of error codes (xErrno) used throughout libx. Every function that can fail returns an xErrno value, providing a consistent error handling pattern across all modules. The companion function xstrerror() converts error codes to human-readable strings for logging and debugging.
Design Philosophy
-
Single Error Enum — All libx modules share one error code enum, avoiding the confusion of module-specific error types. This makes error handling uniform: check for
xErrno_Okeverywhere. -
Descriptive Codes — Each error code maps to a specific failure category (invalid argument, out of memory, wrong state, etc.), giving callers enough information to decide how to handle the error without inspecting errno or platform-specific codes.
-
Human-Readable Messages —
xstrerror()returns a static string for each code, suitable for direct inclusion in log messages. It never returns NULL.
Architecture
graph LR
MODULES["All libx Modules"] -->|"return"| ERRNO["xErrno"]
ERRNO -->|"xstrerror()"| MSG["Human-readable string"]
MSG -->|"xLog()"| LOG["Log output"]
style ERRNO fill:#4a90d9,color:#fff
style MSG fill:#50b86c,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xErrno | int-based enum of error codes |
Enum Values
| Value | Description |
|---|---|
xErrno_Ok | Success |
xErrno_Unknown | Unspecified error (legacy / catch-all) |
xErrno_InvalidArg | NULL or invalid argument |
xErrno_NoMemory | Memory allocation failed |
xErrno_InvalidState | Object is in the wrong state for this call |
xErrno_SysError | Underlying syscall / OS error |
xErrno_NotFound | Requested item does not exist |
xErrno_AlreadyExists | Item already registered / bound |
xErrno_Cancelled | Operation was cancelled |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xstrerror | const char *xstrerror(xErrno err) | Return a human-readable error message. Never returns NULL. | Thread-safe (returns static strings) |
Usage Examples
Error Handling Pattern
#include <stdio.h>
#include <x/base/error.h>
#include <x/base/event.h>
int main(void) {
xEventLoop loop = xEventLoopCreate();
if (!loop) {
fprintf(stderr, "Failed to create event loop\n");
return 1;
}
xErrno err = xEventMod(loop, NULL, xEvent_Read);
if (err != xErrno_Ok) {
fprintf(stderr, "xEventMod failed: %s\n", xstrerror(err));
// Output: "xEventMod failed: NULL or invalid argument"
}
xEventLoopDestroy(loop);
return 0;
}
Propagating Errors
#include <x/base/error.h>
#include <x/base/socket.h>
xErrno setup_socket(xEventLoop loop, xSocket *out) {
xSocket sock = xSocketCreate(loop, AF_INET, SOCK_STREAM, 0,
xEvent_Read, my_callback, NULL);
if (!sock) return xErrno_SysError;
xErrno err = xSocketSetTimeout(sock, 5000, 0);
if (err != xErrno_Ok) {
xSocketDestroy(loop, sock);
return err;
}
*out = sock;
return xErrno_Ok;
}
Use Cases
-
Uniform Error Propagation — Functions return
xErrnoand callers check againstxErrno_Ok. This eliminates the need for module-specific error types. -
Logging and Diagnostics —
xstrerror()provides instant human-readable messages for log output without maintaining separate message tables. -
Error Classification — Callers can switch on specific error codes to implement different recovery strategies (e.g., retry on
xErrno_SysError, abort onxErrno_NoMemory).
Best Practices
- Always check return values. Functions that return
xErrnoshould be checked. Functions that return handles (pointers) should be checked for NULL. - Use
xstrerror()in log messages. It's more informative than printing the raw integer. - Don't compare against raw integers. Always use the enum constants (
xErrno_Ok,xErrno_InvalidArg, etc.) for readability and forward compatibility. - Prefer specific codes over
xErrno_Unknown. When adding new error paths, choose the most specific applicable code.
Comparison with Other Libraries
| Feature | xbase error.h | POSIX errno | Windows HRESULT | GLib GError |
|---|---|---|---|---|
| Type | int enum | int (thread-local) | LONG | Struct (domain + code + message) |
| Scope | Library-wide | System-wide | System-wide | Per-domain |
| String Conversion | xstrerror() | strerror() | FormatMessage() | g_error->message |
| Thread Safety | Return value (inherently safe) | Thread-local global | Return value | Heap-allocated |
| Extensibility | Add to enum | Platform-defined | Facility codes | Custom domains |
| Overhead | Zero (int return) | Zero (thread-local) | Zero (int return) | Heap allocation per error |
Key Differentiator: xbase's error system is intentionally simple — a single enum with descriptive codes and a string conversion function. It avoids the complexity of domain-based systems (GError) and the thread-local pitfalls of POSIX errno, while providing enough granularity for library-level error handling.
Implementation Details
Error Code Values
The error codes are defined as an int-based enum (via XDEF_ENUM), starting from 0:
| Code | Value | Meaning |
|---|---|---|
xErrno_Ok | 0 | Success |
xErrno_Unknown | 1 | Unspecified error (legacy / catch-all) |
xErrno_InvalidArg | 2 | NULL or invalid argument |
xErrno_NoMemory | 3 | Memory allocation failed |
xErrno_InvalidState | 4 | Object is in the wrong state for this call |
xErrno_SysError | 5 | Underlying syscall / OS error |
xErrno_NotFound | 6 | Requested item does not exist |
xErrno_AlreadyExists | 7 | Item already registered / bound |
xErrno_Cancelled | 8 | Operation was cancelled |
Usage Pattern
The idiomatic libx error handling pattern:
xErrno err = xSomeFunction(args);
if (err != xErrno_Ok) {
xLog(false, "operation failed: %s", xstrerror(err));
return err; // propagate
}
Internal Usage
xErrno is used by:
- event.h —
xEventMod(),xEventDel(),xEventWake(),xEventLoopTimerCancel(),xEventLoopSubmit(),xEventLoopWorkCancel(),xEventLoopPost(),xEventLoopSignalWatch() - timer.h —
xTimerCancel() - task.h —
xTaskWait(),xTaskCancel(),xTaskGroupWait() - socket.h —
xSocketSetMask(),xSocketSetTimeout() - heap.h —
xHeapPush(),xHeapUpdate()
heap.h — Min-Heap
Introduction
heap.h provides a generic binary min-heap that stores opaque pointers and orders them via a user-supplied comparison function. Each element carries its heap index (maintained via a callback), enabling O(log n) removal and priority updates by index. It is the core data structure behind xbase's timer subsystem.
Design Philosophy
-
Generic via Function Pointers — The heap stores
void *elements and uses axHeapCmpFuncfor ordering. This makes it reusable for any element type without code generation or macros. -
Index Tracking — A
xHeapSetIdxFunccallback notifies elements of their current position in the heap array. This enables O(1) lookup forxHeapRemove()andxHeapUpdate(), which would otherwise require O(n) search. -
Dynamic Array Backend — The heap uses a dynamically-growing array (2x expansion) starting from a default capacity of 16. This provides cache-friendly access patterns and amortized O(1) growth.
-
No Element Ownership — The heap does not own the elements it stores.
xHeapDestroy()frees the heap structure but NOT the elements. This gives the caller full control over element lifecycle.
Architecture
graph TD
PUSH["xHeapPush(elem)"] --> APPEND["Append to data[size]"]
APPEND --> SIFTUP["Sift Up"]
SIFTUP --> NOTIFY["setidx(elem, new_idx)"]
POP["xHeapPop()"] --> SWAP["Swap data[0] with data[size-1]"]
SWAP --> SIFTDOWN["Sift Down from 0"]
SIFTDOWN --> NOTIFY
REMOVE["xHeapRemove(idx)"] --> SWAP2["Swap data[idx] with data[size-1]"]
SWAP2 --> BOTH["Sift Up + Sift Down"]
BOTH --> NOTIFY
style PUSH fill:#4a90d9,color:#fff
style POP fill:#f5a623,color:#fff
style REMOVE fill:#e74c3c,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xHeapCmpFunc | int (*)(const void *a, const void *b) — Returns negative if a < b, 0 if equal, positive if a > b |
xHeapSetIdxFunc | void (*)(void *elem, size_t idx) — Called when an element's index changes |
xHeap | Opaque handle to a min-heap |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xHeapCreate | xHeap xHeapCreate(xHeapCmpFunc cmp, xHeapSetIdxFunc setidx, size_t cap) | Create a heap. cap = 0 uses default (16). | Not thread-safe |
xHeapDestroy | void xHeapDestroy(xHeap h) | Free the heap. Does NOT free elements. | Not thread-safe |
xHeapPush | xErrno xHeapPush(xHeap h, void *elem) | Insert an element. O(log n). | Not thread-safe |
xHeapPeek | void *xHeapPeek(xHeap h) | Return the minimum element without removing. O(1). | Not thread-safe |
xHeapPop | void *xHeapPop(xHeap h) | Remove and return the minimum element. O(log n). | Not thread-safe |
xHeapRemove | void *xHeapRemove(xHeap h, size_t idx) | Remove element at index. O(log n). | Not thread-safe |
xHeapUpdate | xErrno xHeapUpdate(xHeap h, size_t idx) | Re-heapify after priority change. O(log n). | Not thread-safe |
xHeapSize | size_t xHeapSize(xHeap h) | Return element count. O(1). | Not thread-safe |
Usage Examples
Timer-Style Priority Queue
#include <stdio.h>
#include <stdlib.h>
#include <x/base/heap.h>
typedef struct {
uint64_t deadline;
size_t heap_idx;
char name[32];
} TimerEntry;
static int cmp_entry(const void *a, const void *b) {
const TimerEntry *ea = (const TimerEntry *)a;
const TimerEntry *eb = (const TimerEntry *)b;
if (ea->deadline < eb->deadline) return -1;
if (ea->deadline > eb->deadline) return 1;
return 0;
}
static void set_idx(void *elem, size_t idx) {
((TimerEntry *)elem)->heap_idx = idx;
}
int main(void) {
xHeap heap = xHeapCreate(cmp_entry, set_idx, 0);
TimerEntry entries[] = {
{ .deadline = 300, .name = "C" },
{ .deadline = 100, .name = "A" },
{ .deadline = 200, .name = "B" },
};
for (int i = 0; i < 3; i++)
xHeapPush(heap, &entries[i]);
// Pop in order: A (100), B (200), C (300)
while (xHeapSize(heap) > 0) {
TimerEntry *e = (TimerEntry *)xHeapPop(heap);
printf("%s (deadline=%llu)\n", e->name, e->deadline);
}
xHeapDestroy(heap);
return 0;
}
Use Cases
-
Timer Subsystem —
timer.huses the min-heap to order timer entries by deadline. The timer thread peeks at the minimum to determine how long to sleep, then pops expired entries. -
Event Loop Timers — The event loop's builtin timer heap (
event.h) uses the same pattern to integrate timer dispatch with I/O polling. -
Custom Priority Queues — Any scenario requiring efficient insert/extract-min with O(log n) removal by index.
Best Practices
- Always implement
xHeapSetIdxFunc. Without index tracking,xHeapRemove()andxHeapUpdate()cannot locate elements efficiently. - Store the index in your element struct. The
setidxcallback should write the index into a field of your element (e.g.,elem->heap_idx = idx). - Don't free elements while they're in the heap. Remove them first with
xHeapRemove()orxHeapPop(). - Use
xHeapUpdate()after changing an element's priority. The heap doesn't detect priority changes automatically.
Comparison with Other Libraries
| Feature | xbase heap.h | C++ std::priority_queue | Linux kernel prio_heap | Go container/heap |
|---|---|---|---|---|
| Element Type | void * (generic) | Template | Fixed struct | interface{} |
| Index Tracking | Built-in (setidx callback) | Not available | Not available | Manual (Fix method) |
| Remove by Index | O(log n) | Not supported | Not supported | O(log n) via Remove |
| Update Priority | O(log n) via xHeapUpdate | Not supported | Not supported | O(log n) via Fix |
| Ownership | No (caller owns elements) | Yes (copies/moves) | No | No |
| Thread Safety | Not thread-safe | Not thread-safe | Not thread-safe | Not thread-safe |
Key Differentiator: xbase's heap provides built-in index tracking via the setidx callback, enabling O(log n) removal and priority updates — features that std::priority_queue lacks entirely. This makes it ideal for timer implementations where cancellation is a common operation.
Benchmark
Environment: Apple M3 Pro, 36 GB RAM, macOS 26.4, Release build (
-O2). Source:xbase/heap_bench.cpp
| Benchmark | N | Time (ns) | CPU (ns) | Throughput |
|---|---|---|---|---|
BM_Heap_Push | 8 | 983 | 987 | 8.1 M items/s |
BM_Heap_Push | 64 | 1,694 | 1,699 | 37.7 M items/s |
BM_Heap_Push | 512 | 8,722 | 8,725 | 58.7 M items/s |
BM_Heap_Push | 4,096 | 56,854 | 56,853 | 72.0 M items/s |
BM_Heap_Pop | 8 | 1,020 | 1,024 | 7.8 M items/s |
BM_Heap_Pop | 64 | 2,807 | 2,809 | 22.8 M items/s |
BM_Heap_Pop | 512 | 26,334 | 26,337 | 19.4 M items/s |
BM_Heap_Pop | 4,096 | 297,382 | 297,325 | 13.8 M items/s |
BM_Heap_Remove | 8 | 1,015 | 1,020 | 7.8 M items/s |
BM_Heap_Remove | 64 | 1,808 | 1,811 | 35.3 M items/s |
BM_Heap_Remove | 512 | 8,914 | 8,903 | 57.5 M items/s |
BM_Heap_Remove | 4,096 | 68,017 | 68,016 | 60.2 M items/s |
Key Observations:
- Push throughput scales well with heap size — amortized cost per element decreases as batch size grows, reaching 72M items/s at N=4096.
- Pop is more expensive than push at large N due to the sift-down operation traversing more levels. At N=4096, pop throughput drops to ~14M items/s.
- Remove (random index removal) performs comparably to push, thanks to the O(log n) index-tracked removal. This validates the
setidxcallback design for timer cancellation workloads.
Implementation Details
Data Structure
struct xHeap_ {
void **data; // Dynamic array of element pointers
size_t size; // Current number of elements
size_t cap; // Allocated capacity
xHeapCmpFunc cmp; // Comparison function
xHeapSetIdxFunc setidx; // Index notification callback
};
Array Layout
Index: 0 1 2 3 4 5 6
[min] [ ] [ ] [ ] [ ] [ ] [ ]
│ │ │
│ ├────┤
│ children of 0
├─────┤
parent of 1,2
Parent of i: (i - 1) / 2
Left child of i: 2 * i + 1
Right child of i: 2 * i + 2
Operations and Complexity
| Operation | Function | Time Complexity | Description |
|---|---|---|---|
| Insert | xHeapPush | O(log n) | Append to end, sift up |
| Peek min | xHeapPeek | O(1) | Return data[0] |
| Extract min | xHeapPop | O(log n) | Swap with last, sift down |
| Remove by index | xHeapRemove | O(log n) | Swap with last, sift up + down |
| Update priority | xHeapUpdate | O(log n) | Sift up + down at index |
| Size | xHeapSize | O(1) | Return size field |
| Grow | ensure_cap | Amortized O(1) | 2x realloc |
Sift Operations
- Sift Up — Compare element with parent; swap if smaller. Repeat until heap property is restored or root is reached.
- Sift Down — Compare element with children; swap with the smallest child if it's smaller. Repeat until heap property is restored or a leaf is reached.
Remove by Index
xHeapRemove(h, idx) replaces the element at idx with the last element, then applies both sift-up and sift-down. This handles both cases: the replacement may be smaller (needs to go up) or larger (needs to go down) than its new neighbors.
map.h — Generic Key-Value Map
Introduction
map.h provides a generic associative container that stores opaque key-value pairs and supports multiple backend implementations selected at creation time. Users supply a hash function and an equality function; the map handles collision resolution, resizing, and iteration internally. Three backends are available: separate-chaining hash table, open-addressing hash table, and red-black tree.
Design Philosophy
-
vtable-Driven Polymorphism — All backends share a common
xMapVTabledispatch table. The public API (xMapSet,xMapGet,xMapDel, etc.) forwards calls through function pointers, so callers can switch backends by changing a singlexMapTypeargument without touching any other code. -
Opaque Keys and Values — The map stores
const void *keys andvoid *values. Hash and equality functions are user-supplied, making the map reusable for any key type (strings, integers, structs) without code generation or macros. -
Single-Allocation Construction — The hash and flat backends allocate the struct header and the initial bucket/slot array in one contiguous
calloccall. This reduces allocation overhead and improves cache locality for small maps. -
No Key/Value Ownership — The map does not own the keys or values it stores.
xMapDestroy()frees internal structures but NOT user data. This gives the caller full control over element lifecycle. -
Built-in Hash Helpers — Common hash/equality pairs for C strings (
xMapStrHash/xMapStrEq) and integer keys (xMapIntHash/xMapIntEq) are provided out of the box, covering the two most frequent use cases.
Architecture
graph TD
CREATE["xMapCreate(type, cap, hash, eq)"]
HASH["xMapType_Hash<br/>Separate Chaining"]
FLAT["xMapType_Flat<br/>Open Addressing"]
TREE["xMapType_Tree<br/>Red-Black Tree"]
CREATE -->|"type = Hash"| HASH
CREATE -->|"type = Flat"| FLAT
CREATE -->|"type = Tree"| TREE
API["Public API<br/>Set / Get / Del / Len / Iterate"]
HASH --> VT["xMapVTable dispatch"]
FLAT --> VT
TREE --> VT
VT --> API
style CREATE fill:#4a90d9,color:#fff
style HASH fill:#f5a623,color:#fff
style FLAT fill:#50b86c,color:#fff
style TREE fill:#e74c3c,color:#fff
style API fill:#4a90d9,color:#fff
Internal Dispatch
graph LR
subgraph "xMapBase (common header)"
VTABLE["vtable *"]
HASHFN["hash()"]
EQFN["eq()"]
end
subgraph "xMapVTable"
SET["set()"]
GET["get()"]
DEL["del()"]
LEN["len()"]
ITER["iterate()"]
DESTROY["destroy()"]
end
VTABLE --> SET
VTABLE --> GET
VTABLE --> DEL
VTABLE --> LEN
VTABLE --> ITER
VTABLE --> DESTROY
Every backend struct embeds xMapBase as its first member. The public API casts the opaque xMap handle to xMapBase * to access the vtable, then dispatches to the backend-specific implementation.
Backend Implementations
Hash (Separate Chaining)
┌─────────────────────────────────────────┐
│ xMapHash (single calloc) │
│ base: { vtable, hash, eq } │
│ buckets → ┌──┬──┬──┬──┬──┬──┐ │
│ │ │ │ │ │ │ │ ... │
│ └──┴──┴──┴──┴──┴──┘ │
│ size, cap │
└─────────────────────────────────────────┘
│
▼
┌─────────┐ ┌─────────┐
│ Entry │───▶│ Entry │───▶ NULL
│ key,val │ │ key,val │
└─────────┘ └─────────┘
- Collision resolution: Linked list per bucket.
- Load factor threshold: 75% — triggers 2× resize with full rehash.
- Memory layout: Initial buckets are allocated inline (contiguous with the struct). After the first resize, buckets are a separate allocation.
- Best for: General-purpose use, pointer-heavy keys, high collision tolerance.
Flat (Open Addressing, Linear Probing)
┌─────────────────────────────────────────┐
│ xMapFlat (single calloc) │
│ base: { vtable, hash, eq } │
│ slots → ┌───────┬───────┬───────┐ │
│ │ key │ key │ EMPTY │... │
│ │ val │ val │ │ │
│ │ OCCUP │ OCCUP │ │ │
│ └───────┴───────┴───────┘ │
│ size, cap │
└─────────────────────────────────────────┘
- Collision resolution: Linear probing with tombstone markers for deletion.
- Load factor threshold: 70% — triggers 2× resize (tombstones are discarded during rehash).
- Slot states:
EMPTY(never used),OCCUPIED(active entry),TOMBSTONE(deleted, probe continues). - Memory layout: Initial slots are allocated inline. After the first resize, slots are a separate allocation.
- Best for: Small keys (integers, pointers), cache-friendly sequential access, iteration-heavy workloads.
Tree (Red-Black Tree)
┌───────────┐
│ node(B) │
│ hash=500 │
├─────┬─────┤
│ │ │
┌────▼──┐ ┌▼────────┐
│node(R)│ │ node(R) │
│hash=200│ │ hash=800 │
└───────┘ └──────────┘
- Ordering: Nodes are ordered by 64-bit hash value.
- Hash collisions: When two different keys produce the same hash, the first key is stored in the tree node's primary slot; additional keys are chained in a singly-linked overflow list (
xTreeOverflow). - Deletion optimization: When deleting a primary key that has overflow entries, the first overflow entry is promoted to primary — avoiding an expensive RB-tree fixup.
- No pre-allocation: The
capparameter is ignored; nodes are allocated individually on insert. - Best for: Ordered iteration by hash value, worst-case O(log n) guarantees, workloads where hash table resizing pauses are unacceptable.
Operations and Complexity
| Operation | Hash (avg) | Hash (worst) | Flat (avg) | Flat (worst) | Tree |
|---|---|---|---|---|---|
xMapSet | O(1) | O(n) | O(1) | O(n) | O(log n) |
xMapGet | O(1) | O(n) | O(1) | O(n) | O(log n) |
xMapDel | O(1) | O(n) | O(1) | O(n) | O(log n) |
xMapLen | O(1) | O(1) | O(1) | O(1) | O(1) |
xMapIterate | O(n + cap) | O(n + cap) | O(cap) | O(cap) | O(n) |
xMapCreate | O(cap) | O(cap) | O(cap) | O(cap) | O(1) |
xMapDestroy | O(n + cap) | O(n + cap) | O(cap) | O(cap) | O(n) |
Note: For Hash, iteration visits all buckets (including empty ones). For Flat, iteration visits all slots. Tree iteration is a pure in-order traversal visiting only occupied nodes.
API Reference
Types
| Type | Description |
|---|---|
xMapType | Enum: xMapType_Hash (separate chaining), xMapType_Flat (open addressing), xMapType_Tree (red-black tree) |
xMap | Opaque handle to a map |
xMapHashFunc | uint64_t (*)(const void *key) — Returns a 64-bit hash for the given key |
xMapEqFunc | bool (*)(const void *a, const void *b) — Returns true if two keys are equal |
xMapIterFunc | bool (*)(const void *key, void *val, void *arg) — Iterator callback; return false to stop early |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xMapCreate | xMap xMapCreate(xMapType type, size_t cap, xMapHashFunc hash, xMapEqFunc eq) | Create a map with the specified backend. cap = 0 uses default (16). hash and eq are required. | Not thread-safe |
xMapDestroy | void xMapDestroy(xMap m) | Free the map. Does NOT free user keys/values. NULL is a safe no-op. | Not thread-safe |
xMapSet | xErrno xMapSet(xMap m, const void *key, void *val) | Insert or update a key-value pair. Returns xErrno_Ok or xErrno_NoMemory. | Not thread-safe |
xMapGet | void *xMapGet(xMap m, const void *key) | Look up a value by key. Returns NULL if not found. | Not thread-safe |
xMapDel | void *xMapDel(xMap m, const void *key) | Remove a key-value pair. Returns the removed value, or NULL. | Not thread-safe |
xMapLen | size_t xMapLen(xMap m) | Return the number of entries. O(1). | Not thread-safe |
xMapIterate | void xMapIterate(xMap m, xMapIterFunc fn, void *arg) | Iterate over all entries. Callback returns false to stop early. | Not thread-safe |
Built-in Hash / Equality Helpers
| Function | Description |
|---|---|
xMapStrHash | FNV-1a 64-bit hash for NUL-terminated C strings |
xMapStrEq | strcmp-based equality for C strings |
xMapIntHash | Splitmix64 finalizer for integer keys cast to (void *) |
xMapIntEq | Pointer-value equality for integer keys cast to (void *) |
Usage Examples
String-Keyed Map
#include <stdio.h>
#include <x/base/map.h>
int main(void) {
xMap m = xMapCreate(xMapType_Hash, 0, xMapStrHash, xMapStrEq);
xMapSet(m, "alice", (void *)"engineer");
xMapSet(m, "bob", (void *)"designer");
xMapSet(m, "carol", (void *)"manager");
printf("alice = %s\n", (const char *)xMapGet(m, "alice"));
printf("bob = %s\n", (const char *)xMapGet(m, "bob"));
// Update existing key
xMapSet(m, "alice", (void *)"senior engineer");
printf("alice = %s\n", (const char *)xMapGet(m, "alice"));
// Delete
xMapDel(m, "bob");
printf("bob = %s\n", xMapGet(m, "bob") ? "found" : "not found");
printf("len = %zu\n", xMapLen(m));
xMapDestroy(m);
return 0;
}
Integer-Keyed Map with Iteration
#include <stdio.h>
#include <x/base/map.h>
static bool print_entry(const void *key, void *val, void *arg) {
(void)arg;
printf(" key=%ld val=%ld\n", (long)(intptr_t)key, (long)(intptr_t)val);
return true; // continue iteration
}
int main(void) {
// Use flat map for cache-friendly integer lookups
xMap m = xMapCreate(xMapType_Flat, 0, xMapIntHash, xMapIntEq);
for (int i = 1; i <= 10; i++) {
xMapSet(m, (const void *)(intptr_t)i,
(void *)(intptr_t)(i * i));
}
printf("Entries (%zu):\n", xMapLen(m));
xMapIterate(m, print_entry, NULL);
xMapDestroy(m);
return 0;
}
Choosing a Backend
#include <x/base/map.h>
void example(void) {
// General purpose — good default
xMap hash_map = xMapCreate(xMapType_Hash, 0, xMapStrHash, xMapStrEq);
// Cache-friendly for small integer keys
xMap flat_map = xMapCreate(xMapType_Flat, 0, xMapIntHash, xMapIntEq);
// Ordered iteration, O(log n) worst-case guarantees
xMap tree_map = xMapCreate(xMapType_Tree, 0, xMapStrHash, xMapStrEq);
// ... use them identically via xMapSet/xMapGet/xMapDel ...
xMapDestroy(hash_map);
xMapDestroy(flat_map);
xMapDestroy(tree_map);
}
How to Choose a Backend
| Criteria | Hash | Flat | Tree |
|---|---|---|---|
| Average lookup | O(1) ✅ | O(1) ✅ | O(log n) |
| Worst-case lookup | O(n) | O(n) | O(log n) ✅ |
| Cache locality | Poor (pointer chasing) | Excellent ✅ | Poor (pointer chasing) |
| Iteration speed | Visits empty buckets | Visits empty slots | Visits only entries ✅ |
| Ordered iteration | No | No | Yes (by hash) ✅ |
| Resize pauses | Yes (rehash) | Yes (rehash) | No ✅ |
| Memory overhead | Entry nodes + bucket array | Slot array (inline) ✅ | Node + parent/child pointers |
| Deletion | Free entry node | Tombstone marker | RB fixup or overflow promotion |
| Best for | General purpose | Small keys, hot loops | Ordered access, latency-sensitive |
Rule of thumb: Start with xMapType_Hash. Switch to xMapType_Flat if profiling shows cache misses dominate. Use xMapType_Tree when you need ordered iteration or cannot tolerate resize pauses.
Use Cases
-
Session Management — Store active sessions keyed by session ID (string). The hash backend provides O(1) average lookup for connection dispatch.
-
Configuration Registry — Map string keys to configuration values. The tree backend provides ordered iteration for serialization.
-
Object Caches — Cache computed results keyed by integer IDs. The flat backend's cache-friendly layout minimizes latency for hot-path lookups.
-
Symbol Tables — Compilers and interpreters can use the map to store variable bindings, with string keys and pointer values.
Best Practices
- Always provide both
hashandeq. The map requires both functions; passing NULL for either causesxMapCreateto return NULL. - Use the built-in helpers when possible.
xMapStrHash/xMapStrEqandxMapIntHash/xMapIntEqare well-tested and optimized. - Keys must remain valid while stored. The map stores key pointers, not copies. If you free a key while it's in the map, lookups will read freed memory.
- Don't modify keys in-place. Changing a key's content after insertion will corrupt the map's internal structure (wrong bucket/slot/tree position).
- Pre-size when the count is known. Pass a
caphint toxMapCreateto avoid early resizes. For hash and flat backends, capacity should be a power of 2. - Prefer
xMapType_Hashas the default. It handles the widest range of workloads well. Only switch backends based on profiling data.
Comparison with Other Libraries
| Feature | xbase map.h | C++ std::unordered_map | Go map | GLib GHashTable | uthash |
|---|---|---|---|---|---|
| Language | C99 | C++ | Go | C | C (macros) |
| Key Type | void * (generic) | Template | comparable | gpointer | Struct field |
| Multiple Backends | Hash / Flat / Tree ✅ | Hash only | Hash only | Hash only | Hash only |
| Ordered Iteration | Tree backend ✅ | No (std::map for ordered) | No | No | No |
| Ownership | No (caller owns) | Yes (copies) | Yes (copies) | No | No |
| Thread Safety | Not thread-safe | Not thread-safe | Not thread-safe | Not thread-safe | Not thread-safe |
| Resize Strategy | 2× with rehash | Bucket-based rehash | Incremental | Bucket-based rehash | Bucket-based rehash |
| Intrusive | No | No | No | No | Yes (struct embedding) |
Key Differentiator: xbase's map provides three interchangeable backends behind a single API. Callers can tune the data structure to their workload (cache locality, ordered access, worst-case guarantees) without changing any code beyond the xMapType argument.
Benchmark
Environment: Apple Mac15,7 (12 cores), 36 GB RAM, macOS 26.x, Release build (
-O2). Each result is the median of 3 repetitions (--benchmark_min_time=0.5s --benchmark_repetitions=3). Source:xbase/map_bench.cppThe hash and tree backends allocate nodes through
xSlab(see slab.md); the flat backend uses a single contiguous array and does no per-entry allocation.
Set (Insert)
| Benchmark | N | Time (ns) | CPU (ns) | Throughput |
|---|---|---|---|---|
BM_Map_Set_Hash | 64 | 4,879 | 4,879 | 13.1 M items/s |
BM_Map_Set_Hash | 512 | 9,027 | 9,027 | 56.7 M items/s |
BM_Map_Set_Hash | 4,096 | 56,781 | 56,779 | 72.1 M items/s |
BM_Map_Set_Hash | 32,768 | 713,860 | 713,810 | 45.9 M items/s |
BM_Map_Set_Flat | 64 | 1,061 | 1,062 | 60.2 M items/s |
BM_Map_Set_Flat | 512 | 5,507 | 5,508 | 93.0 M items/s |
BM_Map_Set_Flat | 4,096 | 48,033 | 48,036 | 85.3 M items/s |
BM_Map_Set_Flat | 32,768 | 689,267 | 689,275 | 47.5 M items/s |
BM_Map_Set_Tree | 64 | 5,265 | 5,268 | 12.1 M items/s |
BM_Map_Set_Tree | 512 | 11,232 | 11,233 | 45.6 M items/s |
BM_Map_Set_Tree | 4,096 | 146,120 | 146,120 | 28.0 M items/s |
BM_Map_Set_Tree | 32,768 | 3,154,728 | 3,154,598 | 10.4 M items/s |
Get (Lookup)
| Benchmark | N | Time (ns) | CPU (ns) | Throughput |
|---|---|---|---|---|
BM_Map_Get_Hash | 64 | 214 | 214 | 298.7 M items/s |
BM_Map_Get_Hash | 512 | 1,967 | 1,967 | 260.3 M items/s |
BM_Map_Get_Hash | 4,096 | 20,192 | 20,187 | 202.9 M items/s |
BM_Map_Get_Hash | 32,768 | 207,804 | 207,791 | 157.7 M items/s |
BM_Map_Get_Flat | 64 | 243 | 243 | 263.8 M items/s |
BM_Map_Get_Flat | 512 | 2,276 | 2,276 | 224.9 M items/s |
BM_Map_Get_Flat | 4,096 | 22,258 | 22,256 | 184.0 M items/s |
BM_Map_Get_Flat | 32,768 | 256,893 | 256,885 | 127.6 M items/s |
BM_Map_Get_Tree | 64 | 438 | 438 | 146.1 M items/s |
BM_Map_Get_Tree | 512 | 4,829 | 4,829 | 106.0 M items/s |
BM_Map_Get_Tree | 4,096 | 60,687 | 60,687 | 67.5 M items/s |
BM_Map_Get_Tree | 32,768 | 2,600,910 | 2,600,792 | 12.6 M items/s |
Del (Delete)
| Benchmark | N | Time (ns) | CPU (ns) | Throughput |
|---|---|---|---|---|
BM_Map_Del_Hash | 64 | 1,247 | 1,250 | 51.2 M items/s |
BM_Map_Del_Hash | 512 | 3,366 | 3,371 | 151.9 M items/s |
BM_Map_Del_Hash | 4,096 | 23,818 | 23,814 | 172.0 M items/s |
BM_Map_Del_Hash | 32,768 | 209,060 | 209,018 | 156.8 M items/s |
BM_Map_Del_Flat | 64 | 1,153 | 1,155 | 55.4 M items/s |
BM_Map_Del_Flat | 512 | 3,026 | 3,030 | 169.0 M items/s |
BM_Map_Del_Flat | 4,096 | 21,236 | 21,243 | 192.8 M items/s |
BM_Map_Del_Flat | 32,768 | 270,593 | 268,020 | 122.3 M items/s |
BM_Map_Del_Tree | 64 | 1,788 | 1,791 | 35.7 M items/s |
BM_Map_Del_Tree | 512 | 8,524 | 8,527 | 60.0 M items/s |
BM_Map_Del_Tree | 4,096 | 146,494 | 145,907 | 28.1 M items/s |
BM_Map_Del_Tree | 32,768 | 2,672,192 | 2,672,155 | 12.3 M items/s |
Iterate
| Benchmark | N | Time (ns) | CPU (ns) | Throughput |
|---|---|---|---|---|
BM_Map_Iterate_Hash | 64 | 128 | 128 | 500.2 M items/s |
BM_Map_Iterate_Hash | 512 | 1,030 | 1,030 | 497.3 M items/s |
BM_Map_Iterate_Hash | 4,096 | 8,436 | 8,436 | 485.5 M items/s |
BM_Map_Iterate_Hash | 32,768 | 169,785 | 169,780 | 193.0 M items/s |
BM_Map_Iterate_Flat | 64 | 120 | 120 | 534.7 M items/s |
BM_Map_Iterate_Flat | 512 | 973 | 973 | 526.0 M items/s |
BM_Map_Iterate_Flat | 4,096 | 7,775 | 7,774 | 526.9 M items/s |
BM_Map_Iterate_Flat | 32,768 | 113,315 | 113,308 | 289.2 M items/s |
BM_Map_Iterate_Tree | 64 | 154 | 154 | 416.7 M items/s |
BM_Map_Iterate_Tree | 512 | 1,235 | 1,235 | 414.4 M items/s |
BM_Map_Iterate_Tree | 4,096 | 10,813 | 10,812 | 378.8 M items/s |
BM_Map_Iterate_Tree | 32,768 | 178,903 | 178,901 | 183.2 M items/s |
Key Observations:
- Flat is fastest for small maps. At N≤512, flat's contiguous array layout beats hash on both insert and iterate, and trades evenly with hash on lookup/delete. It is the right choice when capacity fits in a few cache lines.
- Hash scales better at large N. At N=32K, hash sustains 157.7 M lookups/s vs flat's 127.6 M and tree's 12.6 M — separate-chaining avoids the probe-length blowup that hurts flat as load increases.
- Tree pays for ordering. At N=32K, tree set throughput is 10.4 M items/s (~30× slower than flat). Pick tree only when range scans or predictable worst-case latency matter; its iterate throughput remains strong at small N because the red-black walk stays cache-resident.
- Iteration dominates everywhere. Flat peaks at ~535 M items/s (pure sequential scan), hash ~500 M (bucket hop + chain), tree ~415 M (in-order recursion). Use iterate for bulk scans rather than repeatedly calling
xMapGet. - Large-N drops are real. Both flat and hash lose roughly a third of peak throughput between 4K and 32K entries — this is the L2-to-L3 cache boundary, not an algorithmic issue.
list.h — Doubly-Linked Circular List
Introduction
list.h provides an intrusive doubly-linked circular list, derived from the Linux kernel's include/linux/list.h. Instead of storing payloads inside list nodes, the caller embeds an xList node inside their own struct and uses xContainerOf to recover the enclosing struct. This design avoids dynamic allocation for the list itself and works with any element type without generic macros or function pointers.
Design Philosophy
-
Intrusive Design — The list node (
xList) is embedded inside the user's struct rather than wrapping it. This eliminates per-element heap allocation and makes the list usable for any type without templates orvoid *casts. -
Circular Sentinel — The list head is itself an
xListnode whosenextandprevpoint back to itself when empty. This eliminates special-case branching for head/tail operations — every insertion and deletion follows the same pointer manipulation. -
Inline Implementation — All functions are declared
XCAPI_INLINE, so the entire list implementation lives in the header with no separate.cfile. This gives the compiler full visibility for inlining and constant propagation, yielding zero-overhead list operations. -
Poison Pointers — After removal, a node's
nextandprevare overwritten with sentinel values (0xDEAD/0xBEEF). Accessing a removed node's links will trigger an obvious crash, catching use-after-remove bugs early. -
Safe Iteration Macros —
xListForEachSafeandxListForEachEntrySafestash the next pointer before the current node is visited, allowing deletion during iteration without invalidating the loop.
Architecture
graph TD
INIT["xListInit(head)"] --> CIRCULAR["head ⇄ head<br/>(empty circle)"]
ADD["xListAdd(prev, node)"] --> INSERT["Insert after prev"]
ADDH["xListAddHead(head, node)"] --> INSERTH["Insert at head<br/>(= xListAdd(head, node))"]
ADDT["xListAddTail(head, node)"] --> INSERTT["Insert at tail<br/>(= xListAdd(head→prev, node))"]
ADDB["xListAddBefore(next, node)"] --> INSERTB["Insert before next"]
DEL["xListDel(node)"] --> REMOVE["Unlink + poison"]
EMPTY["xListEmpty(head)"] --> CHECK["head→next == head?"]
CIRCULAR --> ADD
CIRCULAR --> ADDH
CIRCULAR --> ADDT
CIRCULAR --> ADDB
ADD --> DEL
ADDH --> DEL
ADDT --> DEL
ADDB --> DEL
style INIT fill:#4a90d9,color:#fff
style ADD fill:#50b86c,color:#fff
style ADDH fill:#50b86c,color:#fff
style ADDT fill:#50b86c,color:#fff
style ADDB fill:#50b86c,color:#fff
style DEL fill:#e74c3c,color:#fff
style EMPTY fill:#f5a623,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xList | Doubly-linked list node. Embed in your struct as a member. |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xListInit | void xListInit(xList *head) | Initialize a list head as an empty circular list | Not thread-safe |
xListAdd | void xListAdd(xList *prev, xList *node) | Insert node after prev | Not thread-safe |
xListAddHead | void xListAddHead(xList *head, xList *node) | Insert node at the head of the list (equivalent to xListAdd(head, node)) | Not thread-safe |
xListAddTail | void xListAddTail(xList *head, xList *node) | Insert node at the tail of the list (equivalent to xListAdd(head->prev, node)) | Not thread-safe |
xListAddBefore | void xListAddBefore(xList *next, xList *node) | Insert node before next | Not thread-safe |
xListDel | void xListDel(xList *node) | Remove node from its list and poison its pointers | Not thread-safe |
xListEmpty | bool xListEmpty(xList *head) | Return true if the list is empty | Not thread-safe |
Macros
| Macro | Parameters | Description |
|---|---|---|
xListForEach(pos, head) | pos: iterator (xList *), head: list head | Iterate over raw list nodes |
xListForEachSafe(pos, tmp, head) | pos: iterator, tmp: temp, head: list head | Iterate with safe deletion support |
xListForEachEntry(pos, head, member) | pos: struct pointer iterator, head: list head, member: name of xList field | Iterate over struct entries via xContainerOf |
xListForEachEntrySafe(pos, tmp, head, member) | pos: struct pointer iterator, tmp: temp struct pointer, head: list head, member: name of xList field | Iterate over struct entries with safe deletion support |
Usage Examples
Basic List Operations
#include <stdio.h>
#include <x/base/list.h>
struct Task {
xList list;
int id;
};
int main(void) {
xList head;
xListInit(&head);
struct Task t1 = { .id = 1 };
struct Task t2 = { .id = 2 };
struct Task t3 = { .id = 3 };
/* Append to the end */
xListAddTail(&head, &t1.list);
xListAddTail(&head, &t2.list);
xListAddTail(&head, &t3.list);
/* Iterate: 1, 2, 3 */
struct Task *pos;
xListForEachEntry(pos, &head, list) {
printf("task id = %d\n", pos->id);
}
/* Remove t2 */
xListDel(&t2.list);
/* Iterate: 1, 3 */
xListForEachEntry(pos, &head, list) {
printf("task id = %d\n", pos->id);
}
return 0;
}
Safe Deletion During Iteration
#include <x/base/list.h>
struct Node {
xList list;
int value;
};
void remove_all(xList *head) {
struct Node *pos, *tmp;
xListForEachEntrySafe(pos, tmp, head, list) {
xListDel(&pos->list);
/* pos is now unlinked; safe to free if dynamically allocated */
}
}
Stack (LIFO) with xListAddHead
#include <x/base/list.h>
struct Item {
xList list;
int data;
};
void stack_push(xList *stack, struct Item *item) {
xListAddHead(stack, &item->list); /* insert at head = top of stack */
}
struct Item *stack_pop(xList *stack) {
if (xListEmpty(stack)) return NULL;
xList *first = stack->next;
xListDel(first);
return xContainerOf(first, struct Item, list);
}
Queue (FIFO) with xListAddTail
#include <x/base/list.h>
struct Entry {
xList list;
int data;
};
void queue_push(xList *queue, struct Entry *entry) {
xListAddTail(queue, &entry->list); /* insert at tail */
}
struct Entry *queue_pop(xList *queue) {
if (xListEmpty(queue)) return NULL;
xList *first = queue->next;
xListDel(first);
return xContainerOf(first, struct Entry, list);
}
Use Cases
-
Timer Entry Queue —
timer.hlinks timer entries via an embeddedxListnode for O(1) insertion and removal of timer callbacks. -
Connection List — Async socket implementations can chain active connections in a list, enabling O(1) connect/disconnect without external allocation.
-
Task Scheduling — A thread pool can maintain per-worker task lists using
xListAddHead/xListAddTail/xListDel, withxListForEachEntrySafefor graceful shutdown that drains and cancels pending tasks. -
Event Callback Chains — Multiple listeners on the same event can be linked in a list, each embedding an
xListnode in their handler struct.
Best Practices
- Always use the safe variants when deleting during iteration.
xListForEach/xListForEachEntrywill crash if the current node is deleted mid-loop. UsexListForEachSafe/xListForEachEntrySafeinstead. - Initialize before use. An uninitialized
xListhas indeterminate pointers. Always callxListInit()before any other operation. - Don't re-add a node without removing it first. Adding a node that is already in a list will corrupt both the old and new lists. Call
xListDel()before re-inserting. - Use
xListAddTail(head, ...)for tail insertion. In a circular list,xListAddTailinserts before the head sentinel, appending to the tail in O(1). Similarly, usexListAddHead(head, ...)for head insertion. - Check poison after removal for debugging. After
xListDel(),node->next == 0xDEADsignals a use-after-remove bug if you accidentally access the node's links.
Comparison with Other Libraries
| Feature | xbase list.h | Linux kernel list.h | C++ std::list | GLib GList | utlist |
|---|---|---|---|---|---|
| Style | Intrusive | Intrusive | Non-intrusive | Non-intrusive | Intrusive (macros) |
| Allocation | None (embedded) | None (embedded) | Per-node heap | Per-node heap | None (embedded) |
| Circular | Yes | Yes | No (sentinel node) | No (NULL-terminated) | Optional |
| Head/Tail Helpers | Yes (xListAddHead, xListAddTail) | Yes (list_add, list_add_tail) | Yes (push_front, push_back) | Yes (g_list_append, g_list_prepend) | No |
| Poison Pointers | Yes | Yes | No | No | No |
| Safe Iteration | Yes (macro) | Yes (macro) | Yes (iterator) | Yes (manual) | No |
| Thread Safety | Not thread-safe | Not thread-safe | Not thread-safe | Not thread-safe | Not thread-safe |
| Inline Implementation | Yes (header-only) | Yes (header-only) | No (template instantiation) | No (separate .c) | Yes (macros) |
Key Differentiator: xbase's list follows the same proven intrusive design as the Linux kernel's list.h, adapted for user-space C99 with xContainerOf (equivalent to kernel's container_of). The inline implementation and poison pointers provide zero-overhead operations and early detection of use-after-remove bugs.
Implementation Details
Data Structure
typedef struct xList {
struct xList *next;
struct xList *prev;
} xList;
Circular Layout
Empty list:
┌──────────────┐
│ head │
│ next ──┐ │
│ prev ──┼──┐ │
│ │ │ │
└────────┼──┼───┘
▼ ▼
(self)
List with three nodes:
head ⇄ A ⇄ B ⇄ C ⇄ head
┌─►──────────────────┐
│ ▼
head ──► A ──► B ──► C ──┘
▲ ◄── ◄── ◄── │
└──────────────────────┘
Operations and Complexity
| Operation | Function / Macro | Time Complexity | Description |
|---|---|---|---|
| Initialize | xListInit | O(1) | Set next = prev = head (circular empty) |
| Insert after | xListAdd | O(1) | Link node after a given node |
| Insert at head | xListAddHead | O(1) | Insert node right after the list head |
| Insert at tail | xListAddTail | O(1) | Insert node right before the list head (tail) |
| Insert before | xListAddBefore | O(1) | Link node before a given node |
| Remove | xListDel | O(1) | Unlink node + poison pointers |
| Is empty | xListEmpty | O(1) | Check head->next == head |
| Iterate | xListForEach | O(n) | Forward traversal (raw xList *) |
| Iterate safe | xListForEachSafe | O(n) | Forward traversal with deletion support |
| Iterate entries | xListForEachEntry | O(n) | Forward traversal (struct pointers via xContainerOf) |
| Iterate entries safe | xListForEachEntrySafe | O(n) | Forward traversal with deletion support (struct pointers) |
Pointer Manipulation
Inserting node after prev:
Before: prev ⇄ next
After: prev ⇄ node ⇄ next
next->prev = node;
node->next = next;
node->prev = prev;
prev->next = node;
Removing node:
Before: prev ⇄ node ⇄ next
After: prev ⇄ next (node: 0xDEAD / 0xBEEF)
next->prev = prev;
prev->next = next;
node->next = 0xDEAD;
node->prev = 0xBEEF;
array.h — Generic Auto-Growing Array
Introduction
array.h provides a type-erased dynamic array that stores fixed-size elements in contiguous memory. Unlike the intrusive list.h, xArray owns its element storage and manages capacity automatically by doubling when more space is needed.
The array stores elements by value (memcpy'd), so each slot is independently addressable. New slots pushed via xArrayPush() are zero-initialized. Lifecycle callbacks (xArrayCallbacks) let the array automatically manage per-element resources: retain on insertion, release on removal, and equality comparison for lookups.
Typical usage:
xArrayCallbacks cbs = { my_retain, my_release, my_equal };
xArray arr = xArrayCreate(sizeof(MyStruct), 0, &cbs);
MyStruct *slot = (MyStruct *)xArrayPush(&arr);
slot->field = value;
...
size_t idx = xArrayFind(arr, &key);
...
xArrayDestroy(arr);
Design Philosophy
-
Type-Erased Container — The array stores elements as raw bytes of a caller-specified size. Cast to the concrete type on access. This avoids macros, templates, or
void **double-indirection while remaining fully generic. -
Callback-Driven Lifecycle — Optional
retain,release, andequalcallbacks let the array own per-element heap resources (strings, sub-allocations) without the caller tracking them manually. If no callbacks are provided, the array behaves like a plainrealloc-based buffer. -
Opaque Handle —
xArrayis an opaque pointer (XDEF_HANDLE). The internal struct (xArray_) is defined only inarray.c, so callers cannot depend on layout details. Growth may relocate the entire object (header + data), which is whyxArrayPushandxArrayResizetakexArray *arrpand update the handle in place. -
Doubling Growth — When capacity is exhausted, the array doubles its capacity (starting from a default of 8). This yields amortised O(1)
Pushand avoids the O(n) per-insert reallocation of naive strategies. -
Zero-Initialised Slots — Every new element is
memsetto zero before the retain callback fires. This means callers can safely checkslot->ptr != NULLinside a release callback without special handling.
Architecture
graph TD
CREATE["xArrayCreate(elem_size, cap, cbs)"] --> ARR["xArray<br/>(opaque handle)"]
PUSH["xArrayPush(&arr)"] --> GROW["Grow if needed<br/>(double capacity)"]
GROW --> ZERO["Zero-init slot"]
ZERO --> RETAIN["retain callback?"]
RETAIN --> SLOT["Return pointer to slot"]
POP["xArrayPop(arr)"] --> RELEASE1["release callback?"]
RELEASE1 --> SHRINK1["len--"]
RESET["xArrayReset(arr)"] --> RELEASE_ALL["release each element"]
RELEASE_ALL --> LEN_ZERO["len = 0<br/>(cap unchanged)"]
DESTROY["xArrayDestroy(arr)"] --> RELEASE_ALL2["release each element"]
RELEASE_ALL2 --> FREE["free(array)"]
RESIZE["xArrayResize(&arr, n)"] --> GROW2["Grow if n > cap"]
RESIZE --> SHRINK2["Shrink if n < len<br/>(release removed)"]
REMOVE["xArrayRemoveRange(arr, start, count)"] --> RELEASE_RANGE["release [start, start+count)"]
RELEASE_RANGE --> SHIFT["memmove survivors left"]
FIND["xArrayFind(arr, key)"] --> EQUAL["equal callback?"]
EQUAL --> LINEAR["Linear scan"]
ARR --> PUSH
ARR --> POP
ARR --> RESET
ARR --> DESTROY
ARR --> RESIZE
ARR --> REMOVE
ARR --> FIND
style CREATE fill:#4a90d9,color:#fff
style PUSH fill:#50b86c,color:#fff
style POP fill:#e74c3c,color:#fff
style RESET fill:#e74c3c,color:#fff
style DESTROY fill:#e74c3c,color:#fff
style RESIZE fill:#f5a623,color:#fff
style REMOVE fill:#e74c3c,color:#fff
style FIND fill:#f5a623,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xArray | Opaque handle to a dynamic array (XDEF_HANDLE). |
xArrayCallbacks | Struct with optional retain, release, and equal callbacks. |
xArrayRetainFunc | Callback type: void (*)(void *elem). Called when an element is added. |
xArrayReleaseFunc | Callback type: void (*)(void *elem). Called when an element is removed. |
xArrayEqualFunc | Callback type: int (*)(const void *elem, const void *key). Called by xArrayFind. |
Lifecycle Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xArrayCreate | xArray xArrayCreate(size_t elem_size, size_t initial_cap, const xArrayCallbacks *cbs) | Create a new array. elem_size must be > 0. initial_cap of 0 uses default (8). cbs may be NULL. | Not thread-safe |
xArrayDestroy | void xArrayDestroy(xArray arr) | Release all elements and free the array. NULL is a no-op. | Not thread-safe |
xArrayReset | void xArrayReset(xArray arr) | Release all elements but keep the allocated storage for reuse. | Not thread-safe |
Mutator Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xArrayPush | void *xArrayPush(xArray *arrp) | Append a zero-initialised element. May realloc (updates *arrp). Returns pointer to new slot, or NULL on failure. | Not thread-safe |
xArrayPop | xErrno xArrayPop(xArray arr) | Remove the last element (calls release). Returns xErrno_InvalidState if empty. | Not thread-safe |
xArrayResize | xErrno xArrayResize(xArray *arrp, size_t new_len) | Set exact length. Growing zero-inits + retain new slots; shrinking releases removed slots. | Not thread-safe |
xArrayRemoveRange | xErrno xArrayRemoveRange(xArray arr, size_t start, size_t count) | Remove elements in [start, start+count). Releases each, then shifts survivors left. | Not thread-safe |
Accessor Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xArrayAt | void *xArrayAt(xArray arr, size_t idx) | Pointer to element at idx. Returns NULL if out of range. | Not thread-safe |
xArrayLen | size_t xArrayLen(xArray arr) | Number of stored elements. | Not thread-safe |
xArrayCap | size_t xArrayCap(xArray arr) | Current capacity (elements before realloc needed). | Not thread-safe |
xArrayData | void *xArrayData(xArray arr) | Raw pointer to element storage. Valid until next mutation. NULL if empty. | Not thread-safe |
xArrayFind | size_t xArrayFind(xArray arr, const void *key) | Index of first element matching key via equal callback. Returns (size_t)-1 if not found or no equal callback. | Not thread-safe |
Usage Examples
Basic Push / Pop
#include <stdio.h>
#include <x/base/array.h>
int main(void) {
xArray arr = xArrayCreate(sizeof(int), 0, NULL);
/* Push some integers. */
for (int i = 0; i < 5; i++) {
int *slot = (int *)xArrayPush(&arr);
*slot = i * 10;
}
/* arr = [0, 10, 20, 30, 40], len = 5 */
/* Pop the last. */
xArrayPop(arr);
/* arr = [0, 10, 20, 30], len = 4 */
/* Read by index. */
for (size_t i = 0; i < xArrayLen(arr); i++) {
printf("arr[%zu] = %d\n", i, *(int *)xArrayAt(arr, i));
}
xArrayDestroy(arr);
return 0;
}
Owning Heap Strings (Release Callback)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <x/base/array.h>
struct Entry {
char *name;
int value;
};
static void entry_release(void *elem) {
struct Entry *e = (struct Entry *)elem;
free(e->name);
e->name = NULL;
}
int main(void) {
xArrayCallbacks cbs = { NULL, entry_release, NULL };
xArray arr = xArrayCreate(sizeof(struct Entry), 4, &cbs);
/* Push entries that own heap-allocated strings. */
const char *names[] = { "alice", "bob", "carol" };
for (int i = 0; i < 3; i++) {
struct Entry *slot = (struct Entry *)xArrayPush(&arr);
slot->name = strdup(names[i]);
slot->value = i;
}
/* Pop one — entry_release frees the string automatically. */
xArrayPop(arr);
/* Reset — releases remaining entries, keeps capacity. */
xArrayReset(arr);
xArrayDestroy(arr);
return 0;
}
Remove a Range
#include <stdio.h>
#include <x/base/array.h>
int main(void) {
xArray arr = xArrayCreate(sizeof(int), 0, NULL);
for (int i = 0; i < 6; i++) {
int *slot = (int *)xArrayPush(&arr);
*slot = i;
}
/* arr = [0, 1, 2, 3, 4, 5] */
/* Remove elements at indices 2, 3 (range [2, 4)) */
xArrayRemoveRange(arr, 2, 2);
/* arr = [0, 1, 4, 5] */
for (size_t i = 0; i < xArrayLen(arr); i++) {
printf("%d\n", *(int *)xArrayAt(arr, i));
}
/* Output: 0 1 4 5 */
xArrayDestroy(arr);
return 0;
}
Finding Elements (Equal Callback)
#include <stdio.h>
#include <string.h>
#include <x/base/array.h>
struct Item {
int id;
char label[32];
};
static int item_equal(const void *elem, const void *key) {
const struct Item *item = (const struct Item *)elem;
const int *id = (const int *)key;
return item->id == *id;
}
int main(void) {
xArrayCallbacks cbs = { NULL, NULL, item_equal };
xArray arr = xArrayCreate(sizeof(struct Item), 0, &cbs);
struct Item *a = (struct Item *)xArrayPush(&arr);
a->id = 10; strcpy(a->label, "alpha");
struct Item *b = (struct Item *)xArrayPush(&arr);
b->id = 20; strcpy(b->label, "beta");
int key = 20;
size_t idx = xArrayFind(arr, &key);
if (idx != (size_t)-1) {
struct Item *found = (struct Item *)xArrayAt(arr, idx);
printf("Found: id=%d label=%s\n", found->id, found->label);
}
xArrayDestroy(arr);
return 0;
}
Bulk Access with xArrayData
#include <stdio.h>
#include <x/base/array.h>
int main(void) {
xArray arr = xArrayCreate(sizeof(int), 0, NULL);
for (int i = 0; i < 100; i++) {
int *slot = (int *)xArrayPush(&arr);
*slot = i;
}
/* Access the raw buffer for fast iteration. */
int *data = (int *)xArrayData(arr);
size_t len = xArrayLen(arr);
long long sum = 0;
for (size_t i = 0; i < len; i++) {
sum += data[i];
}
printf("Sum of 0..99 = %lld\n", sum);
xArrayDestroy(arr);
return 0;
}
Use Cases
-
Session History — The xagent module stores AI session conversation history in an
xArrayofstruct xAgentSessionMsg_. The release callback frees each message's heap-owned strings (text, tool-use arguments, tool-result output), andxArrayRemoveRangehandles history trimming. -
Query Turn Buffers — The xagent module's
xAgentQuery_uses separatexArrayinstances for inputs, produced output, and pending tool calls. The release callbacks clean up per-element resources when the query is destroyed or reset. -
Timer Entry Queue — A timer subsystem can store active timer entries in an
xArray, usingxArrayRemoveRangeto cancel a batch of timers and the release callback to free timer-specific resources. -
General Dynamic Buffer — Any module that needs a grow-only list of fixed-size records (e.g. accumulated log entries, pending DNS queries) can use
xArraywith no callbacks for plain value storage.
Best Practices
- Always pass
xArray *arrptoxArrayPushandxArrayResize. These functions may reallocate the entire array object, invalidating the old handle. Never store the result ofxArrayAt/xArrayDataacross a Push or Resize call. - Use the release callback instead of manual cleanup. If your elements own heap memory, set a release callback that frees those sub-resources. This makes
xArrayPop,xArrayReset, andxArrayDestroysafe without caller-side loops. - Don't call
xArrayPopon an empty array. It returnsxErrno_InvalidState. CheckxArrayLen(arr) > 0first if the array might be empty. - Avoid retaining pointers across mutations.
xArrayAtandxArrayDatareturn pointers into the internal buffer. Any Push, Resize, or RemoveRange may move memory. Copy the data out if you need it to survive. - Prefer
xArrayResetover Destroy+Create. If you need to empty an array but expect to refill it soon,xArrayResetpreserves the allocated capacity, avoiding a fresh allocation cycle. - Use
xArrayRemoveRangefor front/trailing trims. To remove the first N elements:xArrayRemoveRange(arr, 0, N). To trim from the middle:xArrayRemoveRange(arr, start, count). The function handles release callbacks and memmove internally.
Comparison with Other Libraries
| Feature | xbase array.h | C++ std::vector | GLib GArray | apr_array_header_t (APR) |
|---|---|---|---|---|
| Style | Opaque handle | Template class | Opaque struct | Struct + macros |
| Language | C99 | C++ | C | C |
| Growth Strategy | Double | Implementation-defined (usually double) | Double | Manual (apr_array_push) |
| Element Size | Caller-specified | Template parameter | Caller-specified | Caller-specified |
| Lifecycle Callbacks | Yes (retain/release/equal) | No (RAII per element) | No (clear func) | No |
| Range Removal | xArrayRemoveRange | erase(first, last) | No built-in | No built-in |
| Find | xArrayFind (callback) | std::find (algorithm) | No built-in | No built-in |
| Opaque Handle | Yes | No (header-only template) | Yes | No |
| Thread Safety | Not thread-safe | Not thread-safe | Not thread-safe | Not thread-safe |
Key Differentiator: xbase's array combines the low-level control of a C dynamic array with optional lifecycle callbacks that automate per-element resource management — something GArray and APR arrays lack. The opaque handle design hides layout details and allows growth to relocate the entire object safely via the arrp indirection pattern.
Implementation Details
Internal Structure
struct xArray_ {
size_t elem_size; /* bytes per element */
size_t len; /* current element count */
size_t cap; /* allocated capacity (elements) */
xArrayCallbacks cbs; /* optional lifecycle callbacks */
char data[]; /* flexible array member */
};
The xArray_ struct is allocated as a single block: malloc(sizeof(xArray_) + cap * elem_size). The data flexible array member stores elements contiguously starting right after the header.
Growth Strategy
When xArrayPush needs more space than the current capacity allows:
- Compute the next power-of-two capacity that satisfies the demand (starting from
ARRAY_DEFAULT_CAP = 8). reallocthe entire block (header + data).- Update the caller's
xArrayhandle via thearrppointer.
This means any pointer obtained from xArrayAt / xArrayData is invalidated by a subsequent xArrayPush or xArrayResize that triggers growth.
Callback Semantics
| Callback | When Called | Element State |
|---|---|---|
retain | After xArrayPush or xArrayResize (growing) | Zero-initialised, before caller fills fields |
release | xArrayPop, xArrayReset, xArrayDestroy, xArrayResize (shrinking), xArrayRemoveRange | Still in its original memory location |
equal | xArrayFind | Read-only comparison |
Important: The release callback is invoked before the element's memory is overwritten or freed. This allows the callback to extract and free any heap-owned sub-resources the element holds.
Operations and Complexity
| Operation | Function | Time Complexity | Description |
|---|---|---|---|
| Create | xArrayCreate | O(1) | Allocate header + initial data buffer |
| Destroy | xArrayDestroy | O(n) | Release each element + free block |
| Reset | xArrayReset | O(n) | Release each element, keep capacity |
| Push | xArrayPush | Amortised O(1) | Append + grow if needed |
| Pop | xArrayPop | O(1) | Release last + decrement length |
| Resize | xArrayResize | O(n) | Grow or shrink to exact length |
| Remove range | xArrayRemoveRange | O(n) | Release range + memmove survivors |
| Element access | xArrayAt | O(1) | Pointer arithmetic into data |
| Length | xArrayLen | O(1) | Read len field |
| Capacity | xArrayCap | O(1) | Read cap field |
| Raw data | xArrayData | O(1) | Return pointer to first element |
| Find | xArrayFind | O(n) | Linear scan with equal callback |
string.h — SDS-Style Dynamic String
Introduction
string.h provides an SDS-style dynamic string (XString) that is fully compatible with all C string functions (printf %s, strcmp, strlen, …). The header (length + capacity) is hidden before the user-facing pointer, so every XString is a char* — zero interop friction.
Inspired by Redis SDS (Simple Dynamic Strings).
Typical usage:
XString s = XStringCreate("hello");
s = XStringAppend(s, " world");
printf("%s (len=%zu)\n", s, XStringLen(s));
size_t pos = XStringFindStr(s, "world");
if (pos != XSTRING_NONE) {
printf("found at index %zu\n", pos);
}
XStringDestroy(s);
Design Philosophy
-
Binary-Compatible with C Strings —
XStringis atypedef char *. Every XString can be passed directly to any C string API without conversion. It is always NUL-terminated. -
Hidden Header — The metadata (length, capacity) lives in a header placed before the user pointer. This means
XStringis indistinguishable from a regularchar*at the call site, yet length queries are O(1). -
Auto-Growing — Append operations automatically reallocate when capacity is exhausted. Callers must use the return value (
s = XStringAppend(s, "x")) because reallocation may move the string. -
Binary-Safe — Embedded NUL bytes are supported.
XStringCreateLenandXStringAppendLentreat the input as raw bytes. Length is tracked explicitly, not viastrlen. -
Dual-Strategy Search —
XStringFinduses naivememcmpfor short patterns (below a threshold) and platformmemmemfor longer ones, balancing call overhead against algorithmic advantage.
Architecture
graph TD
CREATE["XStringCreate(init)"] --> S["XString<br/>(char*)"]
CREATELEN["XStringCreateLen(data, len)"] --> S
APPEND["XStringAppend(s, str)"] --> GROW["Grow if needed"]
APPENDLEN["XStringAppendLen(s, data, len)"] --> GROW
APPENDFMT["XStringAppendFormat(s, fmt, ...)"] --> GROW
GROW --> UPDATE["Return updated pointer"]
FIND["XStringFind(haystack, needle, len)"] --> THRESH{"needle_len < 32?"}
THRESH -->|Yes| NAIVE["Naive memcmp scan"]
THRESH -->|No| MEMMEM["memmem (platform Two-Way)"]
DUP["XStringDup(s)"] --> S
TRUNCATE["XStringTruncate(s, new_len)"] --> S
CLEAR["XStringClear(s)"] --> S
DESTROY["XStringDestroy(s)"] --> FREE["free(header + data)"]
S --> APPEND
S --> APPENDLEN
S --> APPENDFMT
S --> FIND
S --> DUP
S --> TRUNCATE
S --> CLEAR
S --> DESTROY
style CREATE fill:#4a90d9,color:#fff
style CREATELEN fill:#4a90d9,color:#fff
style APPEND fill:#50b86c,color:#fff
style APPENDLEN fill:#50b86c,color:#fff
style APPENDFMT fill:#50b86c,color:#fff
style FIND fill:#f5a623,color:#fff
style DESTROY fill:#e74c3c,color:#fff
API Reference
Types and Constants
| Type / Constant | Description |
|---|---|
xString | typedef char *. SDS-style dynamic string, compatible with all C string APIs. |
XSTRING_NONE | ((size_t)-1). Sentinel returned by xStringFind / xStringFindStr when the needle is not found. |
Lifecycle Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xStringCreate | xString xStringCreate(const char *init) | Create from C string. init may be NULL (→ empty). | Not thread-safe |
xStringCreateLen | xString xStringCreateLen(const void *init, size_t len) | Create from raw memory (binary-safe). init may be NULL if len == 0. | Not thread-safe |
xStringDestroy | void xStringDestroy(xString s) | Free the string. NULL is a no-op. | Not thread-safe |
xStringDup | xString xStringDup(const xString s) | Deep copy. NULL → NULL. | Not thread-safe |
Append Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xStringAppend | xString xStringAppend(xString s, const char *append) | Append C string. May realloc; use return value. | Not thread-safe |
xStringAppendLen | xString xStringAppendLen(xString s, const void *append, size_t len) | Append raw bytes (binary-safe). | Not thread-safe |
xStringAppendFormat | xString xStringAppendFormat(xString s, const char *fmt, ...) | Append printf-style formatted string. | Not thread-safe |
Truncate / Clear
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xStringTruncate | void xStringTruncate(xString s, size_t new_len) | Shorten to new_len. No-op if new_len > len. Does not shrink allocation. | Not thread-safe |
xStringClear | void xStringClear(xString s) | Reset to empty string "". Does not shrink allocation. | Not thread-safe |
Accessor Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xStringLen | size_t xStringLen(const xString s) | String length in O(1). NULL → 0. | Not thread-safe |
xStringCap | size_t xStringCap(const xString s) | Allocated capacity. NULL → 0. | Not thread-safe |
xStringAvail | size_t xStringAvail(const xString s) | Available space = cap − len. NULL → 0. | Not thread-safe |
Memory Control Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xStringGrow | xString xStringGrow(xString s, size_t add_len) | Pre-allocate for add_len more bytes. Does not change length. | Not thread-safe |
xStringShrinkToFit | xString xStringShrinkToFit(xString s) | Realloc to fit content exactly. On failure, keeps original allocation. | Not thread-safe |
Search Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xStringFind | size_t xStringFind(const xString haystack, const char *needle, size_t needle_len) | Binary-safe search. Returns byte index or XSTRING_NONE. | Not thread-safe |
xStringFindStr | size_t xStringFindStr(const xString haystack, const char *needle) | C string search. Equivalent to xStringFind(haystack, needle, strlen(needle)). Returns byte index or XSTRING_NONE. | Not thread-safe |
Comparison Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xStringCmp | int xStringCmp(const xString s1, const xString s2) | Binary-safe comparison. Returns <0, 0, >0. NULL sorts before non-NULL. | Not thread-safe |
xStringEq | int xStringEq(const xString s1, const xString s2) | Returns non-zero if equal. NULL == NULL is true. | Not thread-safe |
Usage Examples
Basic Create / Append / Destroy
#include <stdio.h>
#include <x/base/string.h>
int main(void) {
xString s = xStringCreate("hello");
s = xStringAppend(s, " world");
printf("%s (len=%zu, cap=%zu)\n", s, xStringLen(s), xStringCap(s));
/* Output: hello world (len=11, cap=64) */
xStringDestroy(s);
return 0;
}
Binary-Safe String (Embedded NUL)
#include <stdio.h>
#include <x/base/string.h>
int main(void) {
char data[] = { 'a', 'b', 'c', '\0', 'd', 'e', 'f' };
xString s = xStringCreateLen(data, 7);
printf("len=%zu\n", xStringLen(s)); /* len=7, NOT 3 */
size_t pos = xStringFind(s, "def", 3);
if (pos != XSTRING_NONE) {
printf("found 'def' at index %zu\n", pos); /* found 'def' at index 4 */
}
xStringDestroy(s);
return 0;
}
Formatted Append
#include <stdio.h>
#include <x/base/string.h>
int main(void) {
xString s = xStringCreate("count: ");
s = xStringAppendFormat(s, "%d items", 42);
printf("%s\n", s); /* count: 42 items */
xStringDestroy(s);
return 0;
}
Search with XSTRING_NONE
#include <stdio.h>
#include <x/base/string.h>
int main(void) {
xString s = xStringCreate("the quick brown fox");
size_t pos = xStringFindStr(s, "brown");
if (pos != XSTRING_NONE) {
printf("'brown' at index %zu\n", pos); /* 'brown' at index 10 */
}
pos = xStringFindStr(s, "cat");
if (pos == XSTRING_NONE) {
printf("'cat' not found\n");
}
xStringDestroy(s);
return 0;
}
Pre-allocation and Shrink
#include <stdio.h>
#include <x/base/string.h>
int main(void) {
xString s = xStringCreate("hello");
/* Pre-allocate 1 KB to avoid repeated reallocs. */
s = xStringGrow(s, 1024);
printf("avail=%zu\n", xStringAvail(s)); /* >= 1024 */
s = xStringAppend(s, " world");
s = xStringShrinkToFit(s);
printf("cap=%zu, len=%zu\n", xStringCap(s), xStringLen(s));
/* cap=11, len=11 */
xStringDestroy(s);
return 0;
}
Comparison and Equality
#include <stdio.h>
#include <x/base/string.h>
int main(void) {
xString a = xStringCreate("abc");
xString b = xStringCreate("abc");
xString c = xStringCreate("abd");
printf("a == b: %d\n", xStringEq(a, b)); /* 1 (true) */
printf("a == c: %d\n", xStringEq(a, c)); /* 0 (false) */
printf("a cmp c: %d\n", xStringCmp(a, c)); /* <0 */
xStringDestroy(a);
xStringDestroy(b);
xStringDestroy(c);
return 0;
}
Use Cases
-
Network Protocol Buffers — xString's binary safety and O(1) length make it ideal for building wire-format messages (HTTP headers, WebSocket frames, STUN attributes) where embedded NULs occur and
strlenis unreliable. -
Log Message Assembly —
xStringAppendFormatprovides a convenient way to build structured log lines incrementally, with automatic growth and no fixed-size buffer overflow risk. -
Configuration String Handling — xString can hold user-provided configuration values, supporting both C-string APIs and explicit-length operations.
xStringFindStrenables simple key-value parsing. -
General String Builder — Any module that needs to concatenate multiple strings or formatted output can use xString as a safer, more ergonomic alternative to manual
malloc/realloc/snprintfmanagement.
Best Practices
- Always use the return value from append/grow functions.
s = xStringAppend(s, "x")— the pointer may change after reallocation. The old pointer remains valid on failure, so you can still use it, but the new data won't be appended. - Use
XSTRING_NONEto check search results.if (xStringFindStr(s, "key") != XSTRING_NONE)is clearer and more idiomatic than comparing against(size_t)-1. - Prefer
xStringCreateLenfor binary data.xStringCreateusesstrleninternally and will stop at the first NUL byte.xStringCreateLencopies exactly the bytes you specify. - Use
xStringClearinstead of Destroy+Create for reuse.xStringClearresets to an empty string while preserving the allocated capacity, avoiding a fresh allocation cycle. - Pre-allocate with
xStringGrowfor known sizes. If you know the approximate final size,xStringGrowavoids multiple intermediate reallocations during incremental appends. - Don't store derived pointers across mutations. Pointers obtained from the
xString(e.g.s + offset) are invalidated by any append or grow operation that triggers reallocation.
Comparison with Other Libraries
| Feature | xbase string.h | Redis SDS | C++ std::string | bstring |
|---|---|---|---|---|
| Style | char* typedef | char* typedef | Class | Opaque struct |
| Language | C99 | C | C++ | C |
| C String Compatible | Yes | Yes | No (.c_str()) | No |
| Binary-Safe | Yes | Yes | Yes | Yes |
| O(1) Length | Yes | Yes | Yes | Yes |
| Auto-Growing Append | Yes | Yes | Yes | Yes |
| Formatted Append | xStringAppendFormat | sdscatprintf | std::format_to | No built-in |
| Search | xStringFind (threshold) | strstr only | find() | bfind |
| Thread Safety | Not thread-safe | Not thread-safe | Not thread-safe | Not thread-safe |
Key Differentiator: xString combines Redis SDS's zero-friction char* compatibility with a threshold-based search strategy and printf-style formatted append — a practical middle ground between the minimalism of Redis SDS and the full feature set of C++ std::string.
Implementation Details
Memory Layout
XStringHeader
┌──────────────┐
│ len (size_t) │
│ cap (size_t) │
└──────────────┘ ← hdr + 1 = user pointer
┌──────────────┐
XString (char*) → │ data … │ ← always NUL-terminated
│ cap + 1 │
└──────────────┘
The XStringHeader is allocated as part of a single malloc block: malloc(sizeof(XStringHeader) + cap + 1). The user receives a pointer to the data area, which is (XStringHeader*)ptr + 1. This layout means:
XStringLen(s)is O(1) — readshdr->lendirectly.scan be passed to anyconst char*API.- The NUL terminator is always written after
lenbytes.
Growth Strategy
When an append exceeds current capacity:
- If current capacity < 1 MB → double the capacity.
- If current capacity ≥ 1 MB → add 1 MB.
- Minimum capacity is
XSTRING_MIN_CAP = 64bytes.
This mirrors the Redis SDS growth policy and provides good amortised O(1) appends without wasting memory on large strings.
Search Strategy
xStringFind uses a threshold-based approach:
| Pattern Length | Algorithm | Rationale |
|---|---|---|
< XSTRING_FIND_THRESHOLD (32) | Naive memcmp scan | Avoids memmem call overhead for short patterns where O(n·m) is negligible. |
≥ XSTRING_FIND_THRESHOLD | Platform memmem | Leverages glibc's Two-Way algorithm (O(n+m) worst case) or equivalent. |
Not-found results return XSTRING_NONE ((size_t)-1), consistent with the ARRAY_NPOS convention used elsewhere in xbase.
Operations and Complexity
| Operation | Function | Time Complexity | Description |
|---|---|---|---|
| Create | xStringCreate | O(n) | Copy init string + allocate header |
| Create (binary) | xStringCreateLen | O(n) | Copy n bytes + allocate header |
| Destroy | xStringDestroy | O(1) | Free the single allocation |
| Duplicate | xStringDup | O(n) | Copy all data into new allocation |
| Append | xStringAppend | Amortised O(n) | May realloc, then memcpy |
| Append (binary) | xStringAppendLen | Amortised O(n) | May realloc, then memcpy |
| Append (format) | xStringAppendFormat | Amortised O(n) | vsnprintf into available space; grow + retry if needed |
| Truncate | xStringTruncate | O(1) | Write NUL, update len |
| Clear | xStringClear | O(1) | Write NUL at index 0, set len = 0 |
| Length | xStringLen | O(1) | Read header field |
| Capacity | xStringCap | O(1) | Read header field |
| Available | xStringAvail | O(1) | cap − len |
| Grow | xStringGrow | O(n) | Pre-allocate, may realloc |
| Shrink to fit | xStringShrinkToFit | O(n) | realloc to exact size |
| Find | xStringFind | O(n·m) or O(n+m) | Threshold-based: naive or memmem |
| Find (C string) | xStringFindStr | O(n·m) or O(n+m) | Delegates to xStringFind |
| Compare | xStringCmp | O(n) | Binary-safe memcmp |
| Equal | xStringEq | O(n) | xStringCmp == 0 |
mpsc.h — Lock-Free MPSC Queue
Introduction
mpsc.h provides a lock-free, intrusive multi-producer single-consumer (MPSC) queue. Multiple threads can push nodes concurrently without locks, while a single consumer thread pops nodes. It is the backbone of xbase's poll-mode timer dispatch and the event loop's offload completion queue.
Design Philosophy
-
Intrusive Design — Nodes embed an
xMpscstruct directly, avoiding heap allocation per enqueue. This is critical for hot paths like timer expiry and offload completion where allocation overhead would be unacceptable. -
Lock-Free Push —
xMpscPush()uses a single atomic exchange (xAtomicXchg) on the tail pointer, making it wait-free for producers. No mutex, no CAS retry loop. -
Single-Consumer Pop —
xMpscPop()is designed for exactly one consumer thread. It uses atomic loads and a single CAS for the edge case of popping the last element. This simplification avoids the ABA problem that plagues multi-consumer designs. -
Minimal Memory Ordering — The implementation uses
xAtomicAcqRelfor the exchange andxAtomicAcquire/xAtomicReleasefor loads/stores, providing the minimum ordering needed for correctness without the overhead of sequential consistency.
Architecture
graph LR
P1["Producer 1"] -->|"xMpscPush"| TAIL["tail"]
P2["Producer 2"] -->|"xMpscPush"| TAIL
P3["Producer 3"] -->|"xMpscPush"| TAIL
HEAD["head"] -->|"xMpscPop"| C["Consumer"]
subgraph "Queue"
HEAD --> N1["Node 1"] --> N2["Node 2"] --> N3["Node 3"]
N3 --- TAIL
end
style P1 fill:#4a90d9,color:#fff
style P2 fill:#4a90d9,color:#fff
style P3 fill:#4a90d9,color:#fff
style C fill:#50b86c,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xMpsc | Intrusive queue node. Embed in your struct and use xContainerOf() to recover the enclosing struct. |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xMpscPush | void xMpscPush(xMpsc **head, xMpsc **tail, xMpsc *node) | Push a node. Wait-free for producers. | Thread-safe (multi-producer) |
xMpscPop | xMpsc *xMpscPop(xMpsc **head, xMpsc **tail) | Pop the oldest node. Returns NULL if empty. | Single-consumer only |
xMpscEmpty | bool xMpscEmpty(xMpsc **head) | Check if the queue is empty. | Thread-safe |
Usage Examples
Basic Producer-Consumer
#include <stdio.h>
#include <pthread.h>
#include <x/base/mpsc.h>
#include <x/base/base.h>
typedef struct {
xMpsc node; // Must embed xMpsc
int value;
} Message;
static xMpsc *g_head = NULL;
static xMpsc *g_tail = NULL;
static void *producer(void *arg) {
Message *msg = (Message *)arg;
xMpscPush(&g_head, &g_tail, &msg->node);
return NULL;
}
int main(void) {
Message msgs[] = {
{ .value = 1 },
{ .value = 2 },
{ .value = 3 },
};
// Push from multiple threads
pthread_t threads[3];
for (int i = 0; i < 3; i++)
pthread_create(&threads[i], NULL, producer, &msgs[i]);
for (int i = 0; i < 3; i++)
pthread_join(threads[i], NULL);
// Pop from single consumer
xMpsc *node;
while ((node = xMpscPop(&g_head, &g_tail)) != NULL) {
Message *msg = xContainerOf(node, Message, node);
printf("Received: %d\n", msg->value);
}
return 0;
}
Use Cases
-
Timer Poll Mode —
timer.huses the MPSC queue in poll mode to pass expired timer entries from the timer thread to the polling thread without locks. -
Event Loop Offload — The event loop's offload mechanism (
event.h) uses an MPSC queue to deliver completed work items from worker threads to the event loop thread. -
xlog Async Logger —
logger.huses the MPSC queue to pass log messages from application threads to the logger's flush thread.
Best Practices
- Embed
xMpscin your struct. Don't allocatexMpscnodes separately. UsexContainerOf()to recover the enclosing struct after popping. - Initialize head and tail to NULL. An empty queue has both pointers set to NULL.
- Only one thread may call
xMpscPop(). The single-consumer constraint is fundamental to the algorithm's correctness. Violating it causes data races. - Don't access a node after pushing it. Once pushed, the node is owned by the queue until popped.
Comparison with Other Libraries
| Feature | xbase mpsc.h | Dmitry Vyukov MPSC | concurrentqueue (C++) | Linux llist |
|---|---|---|---|---|
| Design | Intrusive, lock-free | Intrusive, lock-free | Non-intrusive, lock-free | Intrusive, lock-free |
| Push | Wait-free (1 atomic xchg) | Wait-free (1 atomic xchg) | Lock-free (CAS loop) | Wait-free (1 atomic xchg) |
| Pop | Lock-free (single consumer) | Lock-free (single consumer) | Lock-free (multi-consumer) | Batch pop (splice) |
| Memory Ordering | AcqRel / Acquire / Release | SeqCst | Relaxed + fences | Varies |
| Allocation | None (intrusive) | None (intrusive) | Per-element (internal) | None (intrusive) |
| Multi-Consumer | No | No | Yes | No (batch only) |
| Language | C99 | C/C++ | C++11 | C (kernel) |
Key Differentiator: xbase's MPSC queue is minimal and intrusive — zero allocation overhead, wait-free push, and carefully chosen memory orderings. It's designed specifically for the single-consumer patterns found in event loops and timer systems.
Benchmark
Environment: Apple M3 Pro, 36 GB RAM, macOS 26.4, Release build (
-O2). Source:xbase/mpsc_bench.cpp
| Benchmark | Time (ns) | CPU (ns) | Iterations | Throughput |
|---|---|---|---|---|
BM_Mpsc_SingleProducer | 3,712 | 3,712 | 187,897 | 275.9 M items/s |
BM_Mpsc_MultiProducer/2 | 609,432 | 87,797 | 8,075 | 227.8 M items/s |
BM_Mpsc_MultiProducer/4 | 1,327,965 | 148,356 | 4,768 | 269.6 M items/s |
BM_Mpsc_MultiProducer/8 | 4,466,805 | 292,260 | 1,000 | 273.7 M items/s |
Key Observations:
- Single-producer push/pop achieves ~276M items/s, demonstrating the minimal overhead of the lock-free algorithm.
- Multi-producer scaling maintains ~270M items/s aggregate throughput even with 8 concurrent producers, showing excellent scalability. The wall-clock time increases due to thread synchronization overhead, but per-CPU throughput remains stable.
- The gap between wall-clock time and CPU time in multi-producer benchmarks reflects the cost of thread creation and barrier synchronization, not the queue operations themselves.
Implementation Details
Data Structure
XDEF_STRUCT(xMpsc) {
xMpsc *volatile next; // Pointer to next node
};
The queue is represented by two external pointers:
head— Points to the oldest node (consumer reads from here)tail— Points to the newest node (producers append here)
Push Algorithm
void xMpscPush(xMpsc **head, xMpsc **tail, xMpsc *node) {
node->next = NULL;
xMpsc *prev_tail = xAtomicXchg(tail, node, xAtomicAcqRel);
if (prev_tail)
prev_tail->next = node; // Link to previous tail
else
xAtomicStore(head, node, xAtomicRelease); // First node
}
The key insight: xAtomicXchg atomically replaces the tail and returns the old value. If the old tail was non-NULL, we link it to the new node. If it was NULL (empty queue), we also update the head.
Pop Algorithm
The pop operation handles three cases:
- Empty queue —
headis NULL, return NULL. - Multiple nodes — Advance
headtohead->next, return old head. - Single node — CAS
tailto NULL. If CAS succeeds, also CASheadto NULL. If CAS fails (concurrent push in progress), spin untilhead->nextbecomes non-NULL.
flowchart TD
START["xMpscPop()"]
CHECK_HEAD{"head == NULL?"}
EMPTY["Return NULL"]
CHECK_NEXT{"head->next == NULL?"}
MULTI["Advance head<br/>Return old head"]
CAS_TAIL{"CAS tail → NULL?"}
CAS_HEAD["CAS head → NULL<br/>Return old head"]
SPIN["Spin until head->next != NULL"]
ADVANCE["Advance head<br/>Return old head"]
START --> CHECK_HEAD
CHECK_HEAD -->|Yes| EMPTY
CHECK_HEAD -->|No| CHECK_NEXT
CHECK_NEXT -->|No| MULTI
CHECK_NEXT -->|Yes| CAS_TAIL
CAS_TAIL -->|Success| CAS_HEAD
CAS_TAIL -->|Fail: concurrent push| SPIN
SPIN --> ADVANCE
style EMPTY fill:#e74c3c,color:#fff
style MULTI fill:#50b86c,color:#fff
style CAS_HEAD fill:#50b86c,color:#fff
style ADVANCE fill:#50b86c,color:#fff
Memory Ordering Analysis
| Operation | Ordering | Reason |
|---|---|---|
xAtomicXchg(tail, node) | AcqRel | Acquire: see previous tail's next field. Release: make node visible to consumer. |
xAtomicStore(head, node) | Release | Make the new head visible to the consumer. |
xAtomicLoad(head) | Acquire | See the node written by the producer. |
xAtomicLoad(&head->next) | Acquire | See the next pointer written by the producer. |
xAtomicCasStrong(tail, ...) | Release | Publish the NULL tail to concurrent pushers. |
Thread Safety
xMpscPush()— Thread-safe (multiple producers).xMpscPop()— Single-consumer only. Must not be called concurrently.xMpscEmpty()— Thread-safe (atomic load).
relay.h — Event-Loop-Aware Pub/Sub Relay
Introduction
relay.h provides a lightweight 1:N fan-out pub/sub primitive that lets modules communicate without direct coupling. A publisher calls xRelayEmit(); every subscriber that called xRelayOn() on the same relay handle receives the message.
What makes it different from a simple observer pattern is event-loop-aware dispatch:
- Same event loop — the subscriber callback fires synchronously, inline on the publisher's stack frame. Zero-copy, zero-allocation.
- Different event loop — the relay uses
xEventLoopPost()to enqueue the callback onto the subscriber's loop. The payload is heap-copied once, and the subscriber receives it on the next iteration of its loop.
Named topics are implemented outside the relay — just keep relay handles as global or module-scoped variables. This keeps the relay itself minimal (no hash table, no string lookups).
Design Philosophy
-
Minimal Core — The relay is just a subscriber list + a mutex. It doesn't own any queues, doesn't know about topics, and doesn't allocate in the hot same-loop path.
-
Snapshot-and-Dispatch — On emit, subscribers are snapshotted under the mutex into a stack array (for the common ≤16 case) or a heap array. Callback execution runs entirely outside the critical section, preventing deadlocks and keeping the lock contention window to ~100ns.
-
Same-Loop Zero-Cost — When publisher and subscriber share an event loop, data lives on the publisher's stack and callbacks fire synchronously. No allocation, no queue, no post.
-
Cross-Loop One-Copy — For subscribers on a different event loop, the relay allocates and copies the payload once. The copy is freed after the subscriber callback returns. This trades one heap allocation per cross-loop subscriber for memory safety across thread boundaries.
-
Global Handles for Topic Namespacing — Rather than embedding a topic string hash table inside the relay (which adds a lock and O(text) overhead to every emit), relay handles are kept as module-scoped variables. The module author declares
extern xRelay *g_temperature_relay;and themain()function creates them — a pattern familiar from Qt's signal/slot and Unix domain sockets.
Architecture
graph TD
subgraph "Same Event Loop (zero-copy)"
PUB1["Publisher<br/>Thread A, Loop L1"] -->|"xRelayEmit(r, data, sz)"| RELAY["xRelay"]
RELAY -->|"synchronous call"| SUB1A["Subscriber A<br/>Loop L1"]
RELAY -->|"synchronous call"| SUB1B["Subscriber B<br/>Loop L1"]
end
subgraph "Cross Event Loop (one-copy)"
PUB2["Publisher<br/>Thread A, Loop L1"] -->|"xRelayEmit(r, data, sz)"| RELAY2["xRelay"]
RELAY2 -->|"xEventLoopPost<br/>(copy data)"| QUEUE["L2 Done Queue"]
QUEUE -->|"next iteration"| SUB2["Subscriber C<br/>Loop L2"]
end
style PUB1 fill:#4a90d9,color:#fff
style PUB2 fill:#4a90d9,color:#fff
style RELAY fill:#f5a623,color:#fff
style RELAY2 fill:#f5a623,color:#fff
style SUB1A fill:#50b86c,color:#fff
style SUB1B fill:#50b86c,color:#fff
style SUB2 fill:#50b86c,color:#fff
style QUEUE fill:#9b59b6,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xRelay | Opaque relay handle. Created by xRelayCreate(), destroyed by xRelayDestroy(). |
xRelayFunc | void (*)(void *data, void *arg) — subscriber callback signature. |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xRelayCreate | xRelay *xRelayCreate(void) | Create a new relay with zero subscribers. Returns NULL on OOM. | Thread-safe (single-threaded creation) |
xRelayOn | xErrno xRelayOn(xRelay *r, xRelayFunc fn, void *arg) | Subscribe fn with arg. The current event loop is recorded. Returns xErrno_Ok or xErrno_NoMemory. | Thread-safe |
xRelayOff | void xRelayOff(xRelay *r, xRelayFunc fn, void *arg) | Remove the first subscriber matching {fn, arg}. No-op if not found. | Thread-safe |
xRelayEmit | void xRelayEmit(xRelay *r, const void *data, size_t size) | Emit data to all subscribers. Same-loop callbacks fire synchronously; cross-loop callbacks are posted. | Thread-safe |
xRelayDestroy | void xRelayDestroy(xRelay *r) | Free all subscribers and internal resources. Pending cross-loop dispatches are still delivered. | Thread-safe (single call) |
Usage Examples
Basic Same-Loop Pub/Sub
#include <stdio.h>
#include <x/base/relay.h>
#include <x/base/event.h>
/* Global relay handle — created at startup. */
xRelay *g_sensor_relay;
/* Subscriber callback. */
static void on_reading(void *data, void *arg) {
float *temp = (float *)data;
const char *name = (const char *)arg;
printf("[%s] Temperature: %.1f°C\n", name, temp);
}
/* Publisher. */
static void sensor_publish(void) {
float reading = 23.5f;
xRelayEmit(g_sensor_relay, &reading, sizeof(reading));
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
/* Create the relay — check for OOM. */
g_sensor_relay = xRelayCreate();
if (!g_sensor_relay) { xEventLoopLeave(); xEventLoopDestroy(loop); return 1; }
/* Modules subscribe — all on the same event loop. */
xRelayOn(g_sensor_relay, on_reading, "Display");
xRelayOn(g_sensor_relay, on_reading, "Logger");
/* Publish — both callbacks fire synchronously here. */
sensor_publish();
xRelayDestroy(g_sensor_relay);
xEventLoopLeave();
xEventLoopDestroy(loop);
return 0;
}
Cross-Event-Loop Dispatch
#include <stdio.h>
#include <pthread.h>
#include <x/base/relay.h>
#include <x/base/event.h>
xRelay *g_alarm_relay;
static void on_alarm(void *data, void *arg) {
const char *msg = (const char *)data;
printf("ALARM: %s\n", msg);
}
static void *bg_thread(void *arg) {
/* Background thread has its own event loop. */
xEventLoop bg_loop = xEventLoopCreate();
xEventLoopEnter(bg_loop);
/* Emit from the background loop — subscriber is on the main loop,
* so the relay will copy the string and post it. */
const char *msg = "Pressure too high!";
xRelayEmit(g_alarm_relay, msg, strlen(msg) + 1);
xEventLoopLeave();
xEventLoopDestroy(bg_loop);
return NULL;
}
int main(void) {
xEventLoop main_loop = xEventLoopCreate();
xEventLoopEnter(main_loop);
g_alarm_relay = xRelayCreate();
xRelayOn(g_alarm_relay, on_alarm, NULL);
pthread_t thread;
pthread_create(&thread, NULL, bg_thread, NULL);
pthread_join(thread, NULL);
/* The callback was posted to the main loop's done queue.
* Run one iteration to drain it. */
xEventLoopRun(main_loop, X_RUN_ONCE);
xRelayDestroy(g_alarm_relay);
xEventLoopLeave();
xEventLoopDestroy(main_loop);
return 0;
}
Unsubscribing
/* Register a subscriber that unsubscribes itself on the first emit. */
static xRelayFunc g_self_unsub;
static void on_first_only(void *data, void *arg) {
xRelay *r = (xRelay *)arg;
printf("First and only emit received.\n");
/* Remove self — subsequent emits will not call this. */
xRelayOff(r, g_self_unsub, r);
}
int main(void) {
/* ... setup ... */
xRelay *r = xRelayCreate();
g_self_unsub = on_first_only;
xRelayOn(r, g_self_unsub, r);
int v = 0;
xRelayEmit(r, &v, sizeof(v)); /* prints */
xRelayEmit(r, &v, sizeof(v)); /* no-op: subscriber removed itself */
xRelayDestroy(r);
return 0;
}
Best Practices
-
Create relays at startup, destroy at shutdown. Relays are designed to live for the duration of the program. Avoid creating and destroying them in hot paths.
-
Subscribe during initialisation.
xRelayOn()is cheap but not free (onecalloc+ one mutex lock). Register all subscribers before entering the main loop. -
Same-loop for latency-sensitive subscribers. If a subscriber needs to react within microseconds of a publish, co-locate it on the same event loop to avoid the
xEventLoopPostoverhead. -
Keep payloads small. Cross-loop copies are
memcpy-based. If your payload is multi-kilobyte or contains heap-allocated fields, wrap it in a heap-allocated struct and pass the pointer (the subscriber becomes responsible for freeing it). -
xRelayOffbefore destroying a subscriber's loop. If a subscriber's event loop is being torn down, unsubscribe first. Otherwise, a concurrent emit mayxEventLoopPostto a destroyed loop. -
Self-unsubscribe from a same-loop callback is safe. A subscriber can call
xRelayOff(r, self_fn, self_arg)from within its own emit callback. This is the canonical pattern for fire-once subscribers. -
Unsubscribing other subscribers from a same-loop callback is also safe — the emit snapshot copies subscriber metadata by value, so freeing another subscriber's node mid-dispatch is harmless. However, callers should be aware that
xRelayOffacquires a mutex, which adds latency to the callback path.
Thread Safety
| Operation | Safety |
|---|---|
xRelayCreate | Call once, single-threaded. |
xRelayOn | Thread-safe. Can be called concurrently with xRelayEmit and other xRelayOn calls. |
xRelayOff | Thread-safe. Can be called concurrently with xRelayEmit and other mutations. |
xRelayEmit | Thread-safe. Can be called concurrently from multiple threads. |
xRelayDestroy | Call once, after all other operations have ceased. |
Implementation Details
Data Structures
/* Subscriber node — embedded in the relay's linked list. */
typedef struct xRelaySub_ {
xList node; /* intrusive list node */
xEventLoop loop; /* snapshot from xRelayOn() */
xRelayFunc fn; /* subscriber callback */
void *arg; /* opaque user pointer */
} xRelaySub_;
/* Cross-loop dispatch payload. Allocated once per cross-loop subscriber. */
typedef struct xRelayDispatch_ {
xRelayFunc fn; /* subscriber callback (copied) */
void *arg; /* opaque user pointer (copied) */
void *data; /* heap-copied payload */
} xRelayDispatch_;
/* The relay itself — two members. */
struct xRelay_ {
xList subs; /* subscriber list head */
xMutex lock; /* serialises On/Off mutations */
};
Emit Algorithm
- Count subscribers under the mutex.
- Allocate snapshot array — stack array for ≤16 subscribers, heap otherwise.
- Fill snapshot under the mutex — pointer copies only, no data movement.
- Release the mutex — the critical section is over (~100ns).
- Dispatch — for each subscriber in the snapshot:
- Same loop or NULL loop → call
sub->fn(data, sub->arg)synchronously. - Different loop →
callocaxRelayDispatch_, copy data withmemcpy,xEventLoopPost.
- Same loop or NULL loop → call
Why Not an MPSC Queue?
The subscriber list is read-heavy (emit) and write-rarely (on/off). An MPSC queue would not help — subscribers need to be iterated, not consumed. The linked list + mutex combo is the right data structure for "persistent list, frequent full scans, occasional mutations."
Why 16 Subscribers for the Stack Fast Path?
Most real-world relays have single-digit subscriber counts (display + logger + analytics). The 16-element stack array covers the common case without heap allocation, while the heap fallback handles the rare case of many subscribers without capping the total.
Comparison with Related Primitives
| Feature | xRelay | xMpsc | xNote |
|---|---|---|---|
| Pattern | 1:N fan-out pub/sub | MPMC queue (single consumer) | 1:1 one-shot signal |
| Buffering | None (synchronous or posted) | Unbounded FIFO | None (single flag) |
| Delivery | Per-subscriber loop-aware | Single consumer pops | Waiter polls |
| Allocation per emit | 0 (same-loop), 1 per cross-loop sub | 0 (intrusive) | 0 |
| Use case | Cross-module events | Work queues, timer dispatch | Completion notification |
atomic.h — Atomic Operations
Introduction
atomic.h provides a set of macro wrappers over GCC/Clang __atomic builtins, offering portable atomic operations with explicit memory ordering. These macros are used throughout xbase for reference counting (memory.h), lock-free queues (mpsc.h), and event loop internals (event.h).
Design Philosophy
-
Thin Macro Wrappers — Each macro maps directly to a compiler builtin with zero overhead. No abstraction layers, no runtime dispatch.
-
Explicit Memory Ordering — Every atomic operation requires an explicit memory order parameter (
xAtomicAcquire,xAtomicRelease, etc.), forcing the programmer to think about ordering requirements rather than defaulting to the expensiveSeqCst. -
GCC/Clang Builtins — The
__atomicbuiltins are supported by GCC ≥ 4.7 and all versions of Clang. They generate optimal instructions for each target architecture (x86:lockprefix, ARM:ldrex/strexor LSE atomics).
Architecture
graph TD
subgraph "xbase Atomic Users"
MEMORY["memory.h<br/>xRetain / xRelease<br/>(SeqCst refcount)"]
MPSC["mpsc.h<br/>xMpscPush / xMpscPop<br/>(AcqRel / Acquire / Release)"]
EVENT["event_private.h<br/>inflight counter<br/>(Relaxed)"]
TASK["task.c<br/>pending / done_count<br/>(stdatomic)"]
end
subgraph "atomic.h Macros"
LOAD["xAtomicLoad"]
STORE["xAtomicStore"]
XCHG["xAtomicXchg"]
CAS["xAtomicCas*"]
ADD["xAtomicAdd/Sub"]
FETCH["xAtomicFetch*"]
end
MEMORY --> ADD
MPSC --> XCHG
MPSC --> LOAD
MPSC --> STORE
MPSC --> CAS
EVENT --> FETCH
style MEMORY fill:#4a90d9,color:#fff
style MPSC fill:#f5a623,color:#fff
style EVENT fill:#50b86c,color:#fff
API Reference
See the Operation Macros section above for the complete list. All macros are defined in <x/base/atomic.h> and require no function calls — they expand directly to compiler builtins.
Usage Examples
Atomic Counter
#include <stdio.h>
#include <pthread.h>
#include <x/base/atomic.h>
static int g_counter = 0;
static void *increment(void *arg) {
(void)arg;
for (int i = 0; i < 100000; i++) {
xAtomicAdd(&g_counter, 1, xAtomicRelaxed);
}
return NULL;
}
int main(void) {
pthread_t threads[4];
for (int i = 0; i < 4; i++)
pthread_create(&threads[i], NULL, increment, NULL);
for (int i = 0; i < 4; i++)
pthread_join(threads[i], NULL);
printf("Counter: %d\n", xAtomicLoad(&g_counter, xAtomicRelaxed));
// Output: Counter: 400000
return 0;
}
Spinlock (Educational)
#include <x/base/atomic.h>
typedef struct { int locked; } Spinlock;
static inline void spin_lock(Spinlock *s) {
while (xAtomicXchg(&s->locked, 1, xAtomicAcquire) != 0) {
// Spin
}
}
static inline void spin_unlock(Spinlock *s) {
xAtomicStore(&s->locked, 0, xAtomicRelease);
}
Use Cases
-
Reference Counting —
memory.husesxAtomicAdd/xAtomicSubwithSeqCstordering for thread-safe reference count management. -
Lock-Free Data Structures —
mpsc.husesxAtomicXchgfor wait-free push andxAtomicCasStrongfor the single-element pop edge case. -
Event Loop Internals — The event loop uses
xAtomicFetchAdd/xAtomicFetchSubwithRelaxedordering to track in-flight offload workers.
Best Practices
- Use the weakest sufficient ordering.
Relaxedfor simple counters,Acquire/Releasefor producer-consumer patterns,SeqCstonly when you need a total order visible to all threads. - Prefer
xAtomicCasStrongoverxAtomicCasWeakunless you're in a retry loop where spurious failures are acceptable (e.g., lock-free stack push). - Note the CAS failure ordering. Both CAS macros hardcode
xAtomicRelaxedas the failure ordering. If you need stronger failure ordering, use the rawxAtomicCasmacro directly. - Don't mix with C11
<stdatomic.h>. While both use the same underlying compiler builtins, mixing the two styles in the same translation unit can be confusing. xbase uses<stdatomic.h>intask.cforatomic_size_tbutatomic.hmacros everywhere else.
Comparison with Other Libraries
| Feature | xbase atomic.h | C11 <stdatomic.h> | C++ <atomic> | Linux kernel atomics |
|---|---|---|---|---|
| Style | Macros over __atomic builtins | Language-level types | Template class | Inline functions + asm |
| Memory Order | Explicit parameter | Explicit parameter | Explicit parameter | Implicit (varies) |
| Types | Any scalar (via pointer) | _Atomic qualified types | std::atomic<T> | atomic_t, atomic64_t |
| CAS | xAtomicCasWeak/Strong | atomic_compare_exchange_* | compare_exchange_* | cmpxchg |
| Compiler | GCC ≥ 4.7, Clang | C11 | C++11 | GCC (kernel) |
| Portability | GCC/Clang only | Standard C11 | Standard C++11 | Linux kernel only |
Key Differentiator: xbase's atomic macros are the thinnest possible wrapper — they add naming consistency (xAtomic* prefix) and explicit ordering parameters without any abstraction overhead. They work with any scalar type via pointer, unlike C11's _Atomic qualifier which requires type annotations.
Implementation Details
Memory Order Constants
| Macro | Value | Meaning |
|---|---|---|
xAtomicRelaxed | __ATOMIC_RELAXED | No ordering constraints. Only guarantees atomicity. |
xAtomicConsume | __ATOMIC_CONSUME | Data-dependent ordering (rarely used in practice). |
xAtomicAcquire | __ATOMIC_ACQUIRE | Prevents reads/writes from being reordered before this operation. |
xAtomicRelease | __ATOMIC_RELEASE | Prevents reads/writes from being reordered after this operation. |
xAtomicAcqRel | __ATOMIC_ACQ_REL | Combines Acquire and Release. |
xAtomicSeqCst | __ATOMIC_SEQ_CST | Full sequential consistency. Most expensive. |
Operation Macros
Load / Store
| Macro | Expansion | Description |
|---|---|---|
xAtomicLoad(p, o) | __atomic_load_n(p, o) | Atomically read *p |
xAtomicStore(p, v, o) | __atomic_store_n(p, v, o) | Atomically write v to *p |
Exchange / CAS
| Macro | Expansion | Description |
|---|---|---|
xAtomicXchg(p, v, o) | __atomic_exchange_n(p, v, o) | Atomically swap *p with v, return old value |
xAtomicCasWeak(p, e, d, o) | __atomic_compare_exchange_n(p, e, d, true, o, Relaxed) | Weak CAS (may spuriously fail) |
xAtomicCasStrong(p, e, d, o) | __atomic_compare_exchange_n(p, e, d, false, o, Relaxed) | Strong CAS (no spurious failure) |
Note: Both CAS macros use
xAtomicRelaxedas the failure ordering. The success ordering is specified by theoparameter.
Arithmetic
| Macro | Expansion | Returns |
|---|---|---|
xAtomicAdd(p, v, o) | __atomic_add_fetch(p, v, o) | New value (*p + v) |
xAtomicSub(p, v, o) | __atomic_sub_fetch(p, v, o) | New value (*p - v) |
xAtomicFetchAdd(p, v, o) | __atomic_fetch_add(p, v, o) | Old value (before add) |
xAtomicFetchSub(p, v, o) | __atomic_fetch_sub(p, v, o) | Old value (before sub) |
Bitwise
| Macro | Expansion | Returns |
|---|---|---|
xAtomicAnd(p, v, o) | __atomic_and_fetch(p, v, o) | New value |
xAtomicOr(p, v, o) | __atomic_or_fetch(p, v, o) | New value |
xAtomicXor(p, v, o) | __atomic_xor_fetch(p, v, o) | New value |
xAtomicNand(p, v, o) | __atomic_nand_fetch(p, v, o) | New value |
xAtomicFetchAnd(p, v, o) | __atomic_fetch_and(p, v, o) | Old value |
xAtomicFetchOr(p, v, o) | __atomic_fetch_or(p, v, o) | Old value |
xAtomicFetchXor(p, v, o) | __atomic_fetch_xor(p, v, o) | Old value |
log.h — Thread-Local Log Callback
Introduction
log.h provides a per-thread, callback-based logging mechanism for libx's internal error reporting. Each thread can register its own log callback via xLogSetCallback(); when xLog() is called, the formatted message is dispatched to that callback. If no callback is registered, messages fall back to stderr. On fatal errors, a stack backtrace is captured and abort() is called.
Design Philosophy
-
Thread-Local Callbacks — Each thread has its own log callback and userdata, stored in
__thread(thread-local storage). This avoids global locks and allows different threads to route log messages to different destinations (e.g., the xlog async logger, a test harness, or a custom handler). -
Minimal and Non-Allocating —
xLog()formats into a fixed-size thread-local buffer (XLOG_BUF_SIZE, default 512 bytes). No heap allocation occurs during logging, making it safe to call from low-level code paths. -
Fatal with Backtrace — When
fatal = true,xLog()captures a stack trace viaxBacktrace()before callingabort(). This provides immediate diagnostic information for unrecoverable errors. -
Bridge to xlog — The callback mechanism is designed to integrate with the higher-level
xlogmodule. The xlog logger registers itself as the thread's log callback, so internal libx errors are automatically routed through the async logging pipeline.
Architecture
graph TD
subgraph "Thread 1"
LOG1["xLog()"] --> CB1["Custom Callback"]
end
subgraph "Thread 2"
LOG2["xLog()"] --> CB2["xlog Logger"]
end
subgraph "Thread 3 (no callback)"
LOG3["xLog()"] --> STDERR["stderr"]
end
CB1 --> FILE["Log File"]
CB2 --> XLOG["Async Logger Pipeline"]
style LOG1 fill:#4a90d9,color:#fff
style LOG2 fill:#4a90d9,color:#fff
style LOG3 fill:#4a90d9,color:#fff
API Reference
Macros
| Macro | Default | Description |
|---|---|---|
XLOG_BUF_SIZE | 512 | Format buffer size in bytes. Override before including the header. |
Types
| Type | Description |
|---|---|
xLogCallback | void (*)(const char *msg, const char *backtrace, void *userdata) — Log callback. backtrace is non-NULL only on fatal. |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xLogSetCallback | void xLogSetCallback(xLogCallback cb, void *userdata) | Register (or clear with NULL) the current thread's log callback. | Thread-local (each thread sets its own) |
xLog | void xLog(bool fatal, const char *fmt, ...) | Format and dispatch a log message. If fatal, captures backtrace and calls abort(). | Thread-local (uses calling thread's callback) |
Usage Examples
Basic Logging with Custom Callback
#include <stdio.h>
#include <x/base/log.h>
static void my_log_handler(const char *msg, const char *backtrace,
void *userdata) {
FILE *f = (FILE *)userdata;
fprintf(f, "[MyApp] %s\n", msg);
if (backtrace) {
fprintf(f, "Stack trace:\n%s", backtrace);
}
}
int main(void) {
// Route this thread's logs to a file
FILE *logfile = fopen("app.log", "w");
xLogSetCallback(my_log_handler, logfile);
xLog(false, "Application started, version %d.%d", 1, 0);
xLog(false, "Processing %d items", 42);
// Clear callback (revert to stderr)
xLogSetCallback(NULL, NULL);
xLog(false, "This goes to stderr");
fclose(logfile);
return 0;
}
Fatal Error with Backtrace
#include <x/base/log.h>
void dangerous_operation(void) {
// This will print the message, capture a backtrace, and abort()
xLog(true, "Unrecoverable error: corrupted state detected");
// Never reaches here
}
Use Cases
-
libx Internal Error Reporting — All libx modules use
xLog()to report internal errors (e.g., allocation failures, invalid states). By registering a callback, applications can capture these messages in their logging pipeline. -
xlog Integration — The
xlogmodule registers its logger as the thread's callback viaxLogSetCallback(), routing all internal libx messages through the async logging system. -
Test Frameworks — Test harnesses can register a callback that captures log messages for assertion, rather than letting them go to stderr.
Best Practices
- Register callbacks early. Set up
xLogSetCallback()before calling any libx functions to ensure all messages are captured. - Don't block in callbacks. The callback runs synchronously on the calling thread. Blocking delays the caller. For async logging, use the xlog module.
- Handle NULL backtrace. The
backtraceparameter is NULL for non-fatal messages. Always check before using it. - Be aware of buffer truncation. Messages longer than
XLOG_BUF_SIZEare truncated. Increase the size at compile time if needed.
Comparison with Other Libraries
| Feature | xbase log.h | syslog | fprintf(stderr) | GLib g_log |
|---|---|---|---|---|
| Callback | Per-thread | Global handler | N/A | Global handler |
| Thread Safety | Thread-local (no locks) | Thread-safe (kernel) | Thread-safe (stdio lock) | Thread-safe (global lock) |
| Backtrace | Built-in on fatal | No | No | Optional (G_DEBUG) |
| Allocation | None (stack buffer) | None (kernel) | None (stdio buffer) | Heap (GString) |
| Fatal Handling | abort() with backtrace | N/A | N/A | abort() (G_LOG_FLAG_FATAL) |
| Customization | Per-thread callback | openlog() | Redirect fd | g_log_set_handler() |
Key Differentiator: xbase's log is designed as a lightweight internal error channel, not a full logging framework. Its per-thread callback design avoids global locks and integrates naturally with the xlog async logger for production use.
Implementation Details
Thread-Local State
XDEF_STRUCT(xLogCtx) {
xLogCallback cb; // User callback (NULL = stderr fallback)
void *userdata; // Forwarded to callback
char buf[XLOG_BUF_SIZE]; // Format buffer (512 bytes)
char bt[XLOG_BT_SIZE]; // Backtrace buffer (2048 bytes)
};
static __thread xLogCtx tl_ctx;
Each thread gets ~2.5 KB of thread-local storage for logging. The buffers are reused across calls, so there's no allocation overhead.
xLog() Flow
flowchart TD
CALL["xLog(fatal, fmt, ...)"]
FMT["vsnprintf → tl_ctx.buf"]
CHECK_FATAL{"fatal?"}
BT["xBacktraceSkip(2, bt, size)"]
CHECK_CB{"callback set?"}
CB["cb(msg, backtrace, userdata)"]
STDERR["fprintf(stderr, msg)"]
ABORT["abort()"]
CALL --> FMT
FMT --> CHECK_FATAL
CHECK_FATAL -->|Yes| BT
CHECK_FATAL -->|No| CHECK_CB
BT --> CHECK_CB
CHECK_CB -->|Yes| CB
CHECK_CB -->|No| STDERR
CB --> CHECK_FATAL2{"fatal?"}
STDERR --> CHECK_FATAL2
CHECK_FATAL2 -->|Yes| ABORT
CHECK_FATAL2 -->|No| DONE["Return"]
style ABORT fill:#e74c3c,color:#fff
style DONE fill:#50b86c,color:#fff
Buffer Size Configuration
The format buffer size can be overridden at compile time:
#define XLOG_BUF_SIZE 1024 // Must be defined before #include <x/base/log.h>
#include <x/base/log.h>
backtrace.h — Platform-Adaptive Stack Backtrace
Introduction
backtrace.h captures the current call stack and formats it into a human-readable multi-line string. The unwinding backend is selected at build time with the following priority: libunwind > execinfo (macOS/glibc) > stub (unsupported platforms). It is used internally by xLog() to provide stack traces on fatal errors.
Design Philosophy
-
Build-Time Backend Selection — The backend is chosen via CMake-detected macros (
X_HAS_LIBUNWIND,X_HAS_EXECINFO). This avoids runtime overhead and ensures the best available unwinder is used on each platform. -
Graceful Degradation — On platforms without libunwind or execinfo, a stub backend returns a "not supported" message rather than crashing. This ensures
xBacktrace()is always safe to call. -
Automatic Frame Skipping — Internal frames (
xBacktrace→xBacktraceSkip→bt_capture) are automatically skipped so the output starts from the caller's perspective. Theskipparameter allows additional frames to be skipped (useful when called through wrapper functions likexLog). -
Buffer-Based Output — The caller provides a buffer; no heap allocation occurs. This makes it safe to call from signal handlers, fatal error paths, and low-memory situations.
Architecture
graph TD
API["xBacktrace() / xBacktraceSkip()"]
SELECT{"Build-time selection"}
LIBUNWIND["libunwind<br/>unw_step() loop"]
EXECINFO["execinfo<br/>backtrace() + backtrace_symbols()"]
STUB["stub<br/>'not supported' message"]
BUF["User buffer<br/>(formatted output)"]
API --> SELECT
SELECT -->|X_HAS_LIBUNWIND| LIBUNWIND
SELECT -->|X_HAS_EXECINFO| EXECINFO
SELECT -->|fallback| STUB
LIBUNWIND --> BUF
EXECINFO --> BUF
STUB --> BUF
style LIBUNWIND fill:#50b86c,color:#fff
style EXECINFO fill:#4a90d9,color:#fff
style STUB fill:#f5a623,color:#fff
API Reference
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xBacktrace | int xBacktrace(char *buf, size_t size) | Capture the call stack into buf. Equivalent to xBacktraceSkip(0, buf, size). | Thread-safe (uses only local/stack state) |
xBacktraceSkip | int xBacktraceSkip(int skip, char *buf, size_t size) | Capture the call stack, skipping skip additional frames beyond internal frames. | Thread-safe |
Parameters
| Parameter | Description |
|---|---|
skip | Number of additional frames to skip (0 = no extra skipping) |
buf | Destination buffer. May be NULL (returns 0). |
size | Size of buf in bytes. |
Return Value
Number of bytes written (excluding trailing \0), or 0 if buf is NULL or size is 0.
Usage Examples
Capture and Print Stack Trace
#include <stdio.h>
#include <x/base/backtrace.h>
void foo(void) {
char buf[4096];
int n = xBacktrace(buf, sizeof(buf));
if (n > 0) {
printf("Stack trace:\n%s", buf);
}
}
void bar(void) { foo(); }
int main(void) {
bar();
return 0;
}
Output (with execinfo on macOS):
Stack trace:
#0 0x100003f20 foo+0x20
#1 0x100003f80 bar+0x10
#2 0x100003fa0 main+0x10
Skip Wrapper Frames
#include <x/base/backtrace.h>
// Custom error reporter that skips its own frame
void report_error(const char *msg) {
char bt[2048];
xBacktraceSkip(1, bt, sizeof(bt)); // Skip report_error itself
fprintf(stderr, "Error: %s\nBacktrace:\n%s", msg, bt);
}
Use Cases
-
Fatal Error Diagnostics —
xLog()captures a backtrace on fatal errors, providing immediate context for debugging crashes. -
Debug Assertions — Custom assertion macros can include
xBacktrace()to show where the assertion failed. -
Memory Leak Detection — Record allocation backtraces to identify where leaked objects were created.
Best Practices
- Provide a large enough buffer. 4096 bytes is usually sufficient for 20-30 frames. The output is truncated (not corrupted) if the buffer is too small.
- Link with
-rdynamicon Linux. Without it, the execinfo backend shows only addresses, not symbol names. - Install libunwind for best results on Linux. It provides more accurate unwinding than execinfo, especially through optimized code and signal handlers.
- Don't call from signal handlers with execinfo.
backtrace_symbols()callsmalloc(), which is not async-signal-safe. libunwind is safer in this context.
Comparison with Other Libraries
| Feature | xbase backtrace.h | glibc backtrace() | libunwind | Boost.Stacktrace | Windows CaptureStackBackTrace |
|---|---|---|---|---|---|
| Platform | macOS + Linux + stub | Linux (glibc) | Linux + macOS | Cross-platform | Windows |
| Accuracy | Backend-dependent | Good (glibc) | Excellent | Backend-dependent | Good |
| Symbol Resolution | Built-in | backtrace_symbols() | unw_get_proc_name() | Backend-dependent | SymFromAddr() |
| Allocation | None (user buffer) | malloc() for symbols | None | Heap | None |
| Signal Safety | libunwind: yes, execinfo: no | No (malloc) | Yes | No | Yes |
| Frame Skipping | Built-in (skip param) | Manual | Manual | Manual | FramesToSkip param |
Key Differentiator: xbase's backtrace provides a simple, buffer-based API with automatic frame skipping and graceful degradation across platforms. It's designed for integration into error reporting paths where heap allocation is undesirable.
Implementation Details
Backend Selection
| Backend | Macro | Platform | Quality |
|---|---|---|---|
| libunwind | X_HAS_LIBUNWIND | Linux (with libunwind installed) | Best — accurate unwinding, symbol + offset |
| execinfo | X_HAS_EXECINFO | macOS, Linux (glibc) | Good — requires -rdynamic on Linux for symbols |
| stub | (fallback) | Any | Minimal — returns "not supported" message |
Output Format
Each frame is formatted as:
#0 0x7fff8a1b2c3d symbol_name+0x1a
#1 0x7fff8a1b2c3d another_function+0x42
#2 0x7fff8a1b2c3d <unknown>
#N— Frame number (0 = most recent)0xADDR— Instruction pointer addresssymbol+offset— Function name and offset (if available)<unknown>— When symbol resolution fails
Frame Skipping
Call stack:
bt_capture() ← INTERNAL_SKIP (2 frames)
xBacktraceSkip() ← INTERNAL_SKIP
xLog() ← user skip = 2 (from xLog)
user_function() ← first visible frame
main()
xBacktrace() calls xBacktraceSkip(0, ...), which adds INTERNAL_SKIP = 2 to skip its own frames. xLog() calls xBacktraceSkip(2, ...) to also skip xLog and xLogSetCallback frames.
libunwind Backend
Uses unw_getcontext() → unw_init_local() → unw_step() loop. For each frame:
unw_get_reg(UNW_REG_IP)— Get instruction pointerunw_get_proc_name()— Get symbol name and offset
execinfo Backend
Uses backtrace() to capture frame addresses, then backtrace_symbols() to resolve names. On Linux, link with -rdynamic to export symbols for resolution.
socket.h — Async Socket
Introduction
socket.h provides an async socket abstraction built on top of event.h. It wraps the POSIX socket API with automatic non-blocking setup, event loop registration, and idle-timeout support. When a socket becomes readable, writable, or times out, a single unified callback is invoked with the appropriate event mask. The event loop is obtained implicitly from the current thread context via xEventLoopCurrent().
Design Philosophy
-
Thin Wrapper, Not a Framework —
xSocketadds just enough abstraction to eliminate boilerplate (non-blocking setup,FD_CLOEXEC, event registration) without hiding the underlying fd. You can always retrieve the raw fd viaxSocketFd()for direct system calls. -
Idle-Timeout Semantics — Read and write timeouts are reset on every corresponding I/O event, implementing idle-timeout behavior. This is ideal for detecting dead connections: if no data arrives within the timeout period, the callback fires with
xEvent_Timeout. -
Unified Callback — A single
xSocketFunccallback handles all events (read, write, timeout). Themaskparameter tells you what happened, and thexEvent_Timeoutflag is OR'd withxEvent_ReadorxEvent_Writeto indicate which direction timed out. -
Implicit Event Loop — All functions obtain the event loop from thread-local storage via
xEventLoopCurrent(). The caller must callxEventLoopEnter(loop)before using any socket API.
Architecture
graph TD
APP["Application"] -->|"xSocketCreate()"| SOCKET["xSocket"]
SOCKET -->|"xEventAdd()"| LOOP["xEventLoop"]
LOOP -->|"I/O ready"| TRAMP["trampoline()"]
TRAMP -->|"reset timers"| TIMER["Timer Heap"]
TRAMP -->|"forward"| CB["callback(sock, mask, userp)"]
TIMER -->|"timeout"| TIMEOUT_CB["timeout_cb()"]
TIMEOUT_CB -->|"xEvent_Timeout"| CB
style SOCKET fill:#4a90d9,color:#fff
style LOOP fill:#f5a623,color:#fff
style CB fill:#50b86c,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xSocket | Opaque handle to an async socket |
xSocketFunc | void (*)(xSocket sock, xEventMask mask, void *arg) — Socket event callback |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xSocketCreate | xSocket xSocketCreate(int family, int type, int protocol, xEventMask mask, xSocketFunc callback, void *userp) | Create a non-blocking socket and register with the event loop. | Not thread-safe |
xSocketCreateFromFd | xSocket xSocketCreateFromFd(int fd, xEventMask mask, xSocketFunc callback, void *userp) | Wrap an existing fd into an xSocket. | Not thread-safe |
xSocketDestroy | void xSocketDestroy(xSocket sock) | Cancel timers, remove from event loop, close fd, free handle. Safe with NULL. | Not thread-safe |
xSocketSetMask | xErrno xSocketSetMask(xSocket sock, xEventMask mask) | Change the watched event mask. | Not thread-safe |
xSocketSetTimeout | xErrno xSocketSetTimeout(xSocket sock, int read_timeout_ms, int write_timeout_ms) | Set idle timeouts. Pass 0 to cancel. Replaces previous settings. | Not thread-safe |
xSocketSetCallback | xErrno xSocketSetCallback(xSocket sock, xSocketFunc callback, void *userp) | Replace the callback and user data. | Not thread-safe |
xSocketFd | int xSocketFd(xSocket sock) | Return the underlying fd, or -1 if NULL. | Thread-safe (read-only) |
xSocketMask | xEventMask xSocketMask(xSocket sock) | Return the current event mask, or 0 if NULL. | Thread-safe (read-only) |
Callback Mask Values
| Mask | Meaning |
|---|---|
xEvent_Read | Socket is readable |
xEvent_Write | Socket is writable |
xEvent_Timeout | xEvent_Read | Read idle timeout fired |
xEvent_Timeout | xEvent_Write | Write idle timeout fired |
Usage Examples
TCP Echo Client with Timeout
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <x/base/socket.h>
static void on_socket(xSocket sock, xEventMask mask, void *arg) {
xEventLoop loop = (xEventLoop)arg;
if (mask & xEvent_Timeout) {
printf("Timeout on %s\n",
(mask & xEvent_Read) ? "read" : "write");
xSocketDestroy(sock);
xEventLoopStop(loop);
return;
}
if (mask & xEvent_Read) {
char buf[1024];
ssize_t n;
while ((n = read(xSocketFd(sock), buf, sizeof(buf))) > 0) {
printf("Received: %.*s\n", (int)n, buf);
}
}
if (mask & xEvent_Write) {
const char *msg = "Hello, server!";
write(xSocketFd(sock), msg, strlen(msg));
// Switch to read-only after sending
xSocketSetMask(sock, xEvent_Read);
}
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xSocket sock = xSocketCreate(AF_INET, SOCK_STREAM, 0,
xEvent_Write, on_socket, loop);
if (!sock) return 1;
// Set 5-second read idle timeout
xSocketSetTimeout(sock, 5000, 0);
// Connect (non-blocking)
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(8080),
};
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
connect(xSocketFd(sock), (struct sockaddr *)&addr, sizeof(addr));
xEventLoopRun(loop, X_RUN_DEFAULT);
xEventLoopLeave();
xEventLoopDestroy(loop);
return 0;
}
UDP Receiver with Idle Timeout
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <x/base/socket.h>
static void on_udp(xSocket sock, xEventMask mask, void *arg) {
xEventLoop loop = (xEventLoop)arg;
if (mask & xEvent_Timeout) {
printf("No data for 10 seconds, shutting down.\n");
xSocketDestroy(sock);
xEventLoopStop(loop);
return;
}
if (mask & xEvent_Read) {
char buf[65536];
ssize_t n;
while ((n = read(xSocketFd(sock), buf, sizeof(buf))) > 0) {
printf("UDP: %.*s\n", (int)n, buf);
}
}
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xSocket sock = xSocketCreate(AF_INET, SOCK_DGRAM, 0,
xEvent_Read, on_udp, loop);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(9999),
.sin_addr.s_addr = INADDR_ANY,
};
bind(xSocketFd(sock), (struct sockaddr *)&addr, sizeof(addr));
// 10-second read idle timeout
xSocketSetTimeout(sock, 10000, 0);
xEventLoopRun(loop, X_RUN_DEFAULT);
xEventLoopLeave();
xEventLoopDestroy(loop);
return 0;
}
Use Cases
-
Network Servers — Create listening sockets, accept connections, and manage each client with its own
xSocket+ idle timeout. Dead connections are automatically detected. -
Protocol Clients — Build async clients (HTTP, Redis, etc.) that connect, send requests, and wait for responses with timeout protection.
-
Real-Time Data Feeds — Monitor UDP multicast sockets with idle timeouts to detect feed outages.
Best Practices
- Always drain in edge-triggered mode. Since the underlying event loop is edge-triggered, read/write until
EAGAINin every callback. - Use idle timeouts for connection health. Set
read_timeout_msto detect dead peers. The timeout resets automatically on each read event. - Call
xEventLoopEnter(loop)before using socket API. All socket functions usexEventLoopCurrent()internally to obtain the event loop. - Check the timeout direction. When
xEvent_Timeoutfires, checkmask & xEvent_Readvs.mask & xEvent_Writeto know which direction timed out. - Don't close the fd manually.
xSocketDestroy()closes it for you. Closing it separately leads to double-close bugs.
Comparison with Other Libraries
| Feature | xbase socket.h | POSIX socket API | libuv uv_tcp_t | Boost.Asio |
|---|---|---|---|---|
| Non-blocking Setup | Automatic (SOCK_NONBLOCK + FD_CLOEXEC) | Manual (fcntl) | Automatic | Automatic |
| Event Registration | Automatic (via xEventLoop) | Manual (epoll_ctl / kevent) | Automatic | Automatic |
| Idle Timeout | Built-in (xSocketSetTimeout) | Manual (timer + bookkeeping) | Manual (uv_timer) | Manual (deadline_timer) |
| Callback Style | Single unified callback with mask | N/A (blocking or manual poll) | Separate read/write callbacks | Separate handlers |
| Raw fd Access | xSocketFd() | Direct | uv_fileno() | native_handle() |
| Buffered I/O | No (raw fd) | No | Yes (uv_read_start) | Yes (async_read) |
| Platform | macOS + Linux | POSIX | Cross-platform | Cross-platform |
Key Differentiator: xbase's socket abstraction is intentionally thin — it handles the boilerplate (non-blocking, event registration, idle timeout) but leaves data reading/writing to the caller via the raw fd. This gives maximum flexibility without imposing a buffering strategy.
Implementation Details
Internal Structure
struct xSocket_ {
int fd; // Underlying file descriptor
xEventSource source; // Registered event source
xEventMask mask; // Current event mask
xSocketFunc callback; // User callback
void *userp; // User data
xTimer read_timer; // Read idle timeout timer
xTimer write_timer; // Write idle timeout timer
int read_timeout_ms; // Read timeout setting (0 = disabled)
int write_timeout_ms; // Write timeout setting (0 = disabled)
};
Trampoline Pattern
The socket registers an internal trampoline() function as the event callback with the event loop. This trampoline:
- Resets idle timers — On
xEvent_Read, cancels and re-arms the read timer. OnxEvent_Write, cancels and re-arms the write timer. - Forwards to user callback — Calls
callback(sock, mask, userp)with the original event mask.
This ensures idle timers are always reset transparently, without requiring the user to manage them manually.
Socket Creation
xSocketCreate() performs these steps atomically:
socket(family, type, protocol)— On Linux/BSD withSOCK_CLOEXEC | SOCK_NONBLOCK, both flags are set in one syscall. On other platforms,fcntl()is used as a fallback.xEventAdd(fd, mask, trampoline, socket)— Registers with the event loop (obtained viaxEventLoopCurrent()).- Returns the opaque
xSockethandle.
Timeout Mechanism
sequenceDiagram
participant App
participant Socket as xSocket
participant L as xEventLoop
participant Timer as Timer Heap
App->>Socket: xSocketSetTimeout(sock, 5000, 3000)
Socket->>Timer: arm read timer (5s)
Socket->>Timer: arm write timer (3s)
Note over L: Data arrives on fd
L->>Socket: trampoline(fd, xEvent_Read)
Socket->>Timer: cancel + re-arm read timer (5s)
Socket->>App: callback(sock, xEvent_Read)
Note over Timer: 5 seconds of silence...
Timer->>Socket: read_timeout_cb()
Socket->>App: callback(sock, xEvent_Timeout | xEvent_Read)
io.h — Abstract I/O Interfaces
Introduction
io.h defines four lightweight I/O interfaces — xReader, xWriter, xSeeker, xCloser — inspired by Go's io.Reader / io.Writer / io.Seeker / io.Closer. Each interface is a small struct containing a function pointer and an opaque void *ctx, making it trivial to adapt any object that provides the matching function signature.
On top of these interfaces, io.h provides a set of convenience functions (xRead, xReadFull, xReadAll, xWrite, xWritev, xSeek, xClose) that operate generically on any implementation, enabling code reuse across TCP connections, TLS streams, file descriptors, in-memory buffers, and more.
Design Philosophy
-
Value-Type Interfaces — Each interface is a plain struct (function pointer + context), not a heap-allocated object. They are cheap to copy, pass by value, and require no memory management.
-
POSIX Semantics — Function signatures mirror their POSIX counterparts:
read(2),writev(2),lseek(2),close(2). This makes the learning curve near-zero for C developers. -
Composable Helpers — Higher-level functions like
xReadFullandxReadAllare built on top ofxReader, so any object that provides a reader automatically gains these capabilities. -
Zero-Initialized = Invalid — A zero-initialized struct (all NULL) is treated as "not set". Convenience functions can detect this and return an error instead of crashing.
Architecture
graph TD
subgraph "Interfaces"
R["xReader<br/>ssize_t read(ctx, buf, len)"]
W["xWriter<br/>ssize_t writev(ctx, iov, iovcnt)"]
S["xSeeker<br/>off_t seek(ctx, offset, whence)"]
C["xCloser<br/>int close(ctx)"]
end
subgraph "Convenience Functions"
XR["xRead"]
XRF["xReadFull"]
XRA["xReadAll"]
XW["xWrite"]
XWV["xWritev"]
XS["xSeek"]
XC["xClose"]
end
subgraph "Implementations"
TCP["xTcpConn<br/>xTcpConnReader / xTcpConnWriter"]
IOB["xIOBuffer<br/>(read/writev funcs)"]
FD["File Descriptor<br/>(custom wrapper)"]
end
XR --> R
XRF --> R
XRA --> R
XW --> W
XWV --> W
XS --> S
XC --> C
TCP -.->|"adapts to"| R
TCP -.->|"adapts to"| W
IOB -.->|"adapts to"| R
IOB -.->|"adapts to"| W
FD -.->|"adapts to"| R
FD -.->|"adapts to"| W
style R fill:#4a90d9,color:#fff
style W fill:#4a90d9,color:#fff
style S fill:#4a90d9,color:#fff
style C fill:#4a90d9,color:#fff
style XRF fill:#50b86c,color:#fff
style XRA fill:#50b86c,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xReader | Abstract reader — { ssize_t (*read)(void*, void*, size_t), void *ctx } |
xWriter | Abstract writer — { ssize_t (*writev)(void*, const struct iovec*, int), void *ctx } |
xSeeker | Abstract seeker — { off_t (*seek)(void*, off_t, int), void *ctx } |
xCloser | Abstract closer — { int (*close)(void*), void *ctx } |
Functions
| Function | Signature | Description |
|---|---|---|
xRead | ssize_t xRead(xReader r, void *buf, size_t len) | Single read; returns bytes read, 0 on EOF, -1 on error |
xWrite | ssize_t xWrite(xWriter w, const void *buf, size_t len) | Write a contiguous buffer (wraps into single iovec) |
xWritev | ssize_t xWritev(xWriter w, const struct iovec *iov, int iovcnt) | Scatter-gather write |
xSeek | off_t xSeek(xSeeker s, off_t offset, int whence) | Reposition offset (SEEK_SET / SEEK_CUR / SEEK_END) |
xClose | int xClose(xCloser c) | Close the underlying resource |
xReadFull | ssize_t xReadFull(xReader r, void *buf, size_t len) | Read exactly len bytes, retrying on partial reads and EAGAIN/EINTR |
xReadAll | int xReadAll(xReader r, void **out, size_t *out_len) | Read until EOF into a malloc'd buffer; caller must free(*out) |
Usage Examples
Creating a Custom Reader
#include <x/base/io.h>
#include <unistd.h>
// Adapt a file descriptor into an xReader
static ssize_t fd_read(void *ctx, void *buf, size_t len) {
int fd = (int)(intptr_t)ctx;
return read(fd, buf, len);
}
xReader make_fd_reader(int fd) {
xReader r;
r.read = fd_read;
r.ctx = (void *)(intptr_t)fd;
return r;
}
Reading Exactly N Bytes
#include <x/base/io.h>
void read_header(xReader r) {
char header[64];
ssize_t n = xReadFull(r, header, sizeof(header));
if (n < 0) {
// error
} else if ((size_t)n < sizeof(header)) {
// EOF before full header
} else {
// got all 64 bytes
}
}
Reading All Data Until EOF
#include <x/base/io.h>
#include <stdlib.h>
void read_body(xReader r) {
void *data;
size_t data_len;
if (xReadAll(r, &data, &data_len) == 0) {
// process data (data_len bytes at data)
free(data);
} else {
// error
}
}
Using with xTcpConn
xTcpConn (from <x/net/tcp.h>) provides adapter functions that return xReader and xWriter bound to the connection's transport layer. This allows TCP connections to be used with all generic I/O helpers:
#include <x/base/io.h>
#include <x/net/tcp.h>
void handle_connection(xTcpConn conn) {
// Get I/O adapters from the TCP connection
xReader r = xTcpConnReader(conn);
xWriter w = xTcpConnWriter(conn);
// Read a fixed-size header
char header[16];
ssize_t n = xReadFull(r, header, sizeof(header));
if (n < (ssize_t)sizeof(header)) return;
// Read the entire body until the peer closes
void *body;
size_t body_len;
if (xReadAll(r, &body, &body_len) != 0) return;
// Echo back through the generic writer
xWrite(w, body, body_len);
free(body);
}
Scatter-Gather Write
#include <x/base/io.h>
void send_http_response(xWriter w) {
const char *header = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n";
const char *body = "Hello";
struct iovec iov[2] = {
{ .iov_base = (void *)header, .iov_len = strlen(header) },
{ .iov_base = (void *)body, .iov_len = 5 },
};
xWritev(w, iov, 2);
}
Integration with xTcpConn
xTcpConn provides two adapter functions that bridge the TCP connection to the generic I/O interfaces:
| Function | Returns | Description |
|---|---|---|
xTcpConnReader(conn) | xReader | Reader bound to transport.read — equivalent to xTcpConnRecv |
xTcpConnWriter(conn) | xWriter | Writer bound to transport.writev — equivalent to xTcpConnSendIov |
These adapters are zero-allocation: they copy the function pointer and context from the connection's internal xTransport into a stack-allocated struct. The returned interfaces are valid as long as the connection (and its transport) remains alive.
Why no xCloser adapter? xTcpConnClose() requires an xEventLoop parameter to properly unregister the socket from the event loop, which does not fit the int (*close)(void *ctx) signature.
Best Practices
- Prefer
xReadFullover manual loops when you need an exact number of bytes. It handlesEAGAIN,EINTR, and partial reads correctly. - Always
free()the buffer fromxReadAllon success. On error, the function cleans up internally. - Use
xWritefor simple writes,xWritevfor multi-buffer writes.xWriteis a thin wrapper that constructs a single iovec — no performance penalty. - Check for zero-initialized interfaces before passing them to helpers. If
xTcpConnReader(NULL)returns a zero struct, callingxReadon it will dereference a NULL function pointer. - Obtain adapters once, use many times. Since
xTcpConnReader/xTcpConnWriterare value types, you can call them once at the start of a handler and reuse the result throughout.
Comparison with Other Libraries
| Feature | xbase io.h | Go io.Reader/Writer | POSIX read/write | C++ std::iostream |
|---|---|---|---|---|
| Abstraction | Struct (fn ptr + ctx) | Interface (vtable) | Raw syscall | Class hierarchy |
| Allocation | Zero (stack value) | Heap (interface value) | N/A | Heap (stream object) |
| Composability | Via helper functions | Via io.Copy, io.ReadAll, etc. | Manual loops | Via stream operators |
| Scatter-Gather | Built-in (xWritev) | No (use io.MultiWriter) | writev(2) | No |
| Read-Until-EOF | xReadAll (malloc'd buffer) | io.ReadAll ([]byte) | Manual loop | std::istreambuf_iterator |
| Error Model | Return value (-1 + errno) | (n, error) tuple | Return value (-1 + errno) | Stream state flags |
Implementation Details
Interface Structs
Each interface is a two-field struct:
| Interface | Function Pointer | Semantics |
|---|---|---|
xReader | ssize_t (*read)(void *ctx, void *buf, size_t len) | Returns bytes read, 0 on EOF, -1 on error |
xWriter | ssize_t (*writev)(void *ctx, const struct iovec *iov, int iovcnt) | Returns bytes written, -1 on error |
xSeeker | off_t (*seek)(void *ctx, off_t offset, int whence) | Returns resulting offset, -1 on error |
xCloser | int (*close)(void *ctx) | Returns 0 on success, -1 on failure |
xReadFull — Retry Logic
xReadFull loops calling r.read until exactly len bytes are read or EOF is reached. It automatically retries on EAGAIN and EINTR, making it suitable for both blocking and non-blocking file descriptors:
while (total < len):
n = r.read(ctx, buf + total, len - total)
if n > 0: total += n
if n == 0: break // EOF
if n == -1:
if EAGAIN or EINTR: continue
else: return -1 // real error
return total
xReadAll — Dynamic Buffer Growth
xReadAll reads until EOF into a dynamically allocated buffer. It starts with a 4096-byte allocation and doubles the capacity each time the buffer fills up:
cap = 4096, buf = malloc(cap)
loop:
if total == cap: realloc(buf, cap * 2)
n = r.read(ctx, buf + total, cap - total)
if n > 0: total += n
if n == 0: *out = buf, *out_len = total, return 0
if n == -1:
if EAGAIN or EINTR: continue
else: free(buf), return -1
The caller is responsible for freeing the returned buffer with free().
xWrite — Single Buffer Convenience
xWrite wraps a contiguous buffer into a single struct iovec and delegates to w.writev, avoiding the need for callers to construct iovec arrays for simple writes:
ssize_t xWrite(xWriter w, const void *buf, size_t len) {
struct iovec iov = { .iov_base = (void *)buf, .iov_len = len };
return w.writev(w.ctx, &iov, 1);
}
command.h — Async Command Executor
Introduction
command.h provides an asynchronous command executor that spawns child processes over xEventLoop with stdout/stderr capture, streaming, or discard modes. It uses fork() + execvp() with independent process groups for clean timeout/cancellation via killpg(). Child exit detection is done through SIGCHLD delivered via xEventLoopSignalWatch().
Design Philosophy
-
Event-Loop Integrated — Commands are spawned asynchronously and their lifecycle (I/O readiness, timeout, exit) is managed entirely through the event loop. No blocking
waitpid()polling is needed. -
Independent Process Groups — Each child is placed in its own process group via
setpgid(). This ensures thatkillpg()on timeout/cancellation kills the entire process tree (including any grandchildren), avoiding orphaned processes. -
Flexible Output Handling — Three output modes (Capture, Stream, Discard) cover the full spectrum from "I need the full output" to "I just want a live feed" to "I don't care about output at all." Each of stdout and stderr can be configured independently.
-
PTY Support — An optional pseudo-terminal mode (
xCommandInput_Pty) allocates a PTY for the child, merging stdout and stderr into a single stream. This is essential for programs that behave differently when connected to a terminal (e.g., colored output, interactive prompts). -
Graceful Cancellation —
xCommandExecutorCancel()sendsSIGTERMfirst, then escalates toSIGKILLafter a grace period. This gives well-behaved processes a chance to clean up.
Architecture
graph TD
APP["Application"] -->|"xCommandExecutorSubmit()"| EXEC["xCommandExecutor<br/>(Executor)"]
EXEC -->|"fork() + execvp()"| CHILD["Child Process"]
subgraph "Event Loop"
EXEC -->|"SIGCHLD watch"| SIGCHLD["Signal Watch"]
EXEC -->|"stdout/stderr fd"| IOWATCH["I/O Watch"]
EXEC -->|"timeout_ms"| TIMER["Timer Watch"]
end
CHILD -->|"exit"| SIGCHLD
CHILD -->|"stdout/stderr data"| IOWATCH
TIMER -->|"timeout fired"| EXEC
SIGCHLD -->|"on_done"| APP
IOWATCH -->|"on_stdout / on_stderr"| APP
style APP fill:#4a90d9,color:#fff
style EXEC fill:#f5a623,color:#fff
style CHILD fill:#50b86c,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xCommandOutputMode | Enum: xCommandOutput_Capture, xCommandOutput_Stream, xCommandOutput_Discard |
xCommandInputMode | Enum: xCommandInput_Pipe (default), xCommandInput_Pty |
xCommandConf | Configuration struct for a command invocation |
xCommandResult | Result struct populated on command completion |
xCommandExecutor | Opaque handle to a command executor |
xCommandExecutorOutputFunc | void (*)(xCommandExecutor, const char *data, size_t len, void *ud) — streaming output callback |
xCommandExecutorDoneFunc | void (*)(xCommandExecutor, const xCommandResult *result, void *ud) — completion callback |
xCommandConf Fields
| Field | Type | Description |
|---|---|---|
cmd | const char * | Program path (required, searched in $PATH) |
argv | const char ** | Argument vector (NULL-terminated, may be NULL) |
envp | const char ** | Environment (NULL = inherit parent) |
cwd | const char * | Working directory (NULL = inherit) |
timeout_ms | uint64_t | Timeout in milliseconds (0 = no timeout) |
stdout_cap | size_t | Max stdout bytes to capture (0 = unlimited) |
stderr_cap | size_t | Max stderr bytes to capture (0 = unlimited, ignored in PTY mode) |
stdout_mode | xCommandOutputMode | How to handle stdout |
stderr_mode | xCommandOutputMode | How to handle stderr (ignored in PTY mode) |
input_mode | xCommandInputMode | xCommandInput_Pipe (default) or xCommandInput_Pty |
xCommandResult Fields
| Field | Type | Description |
|---|---|---|
exit_code | int | Exit status (valid if signaled == 0) |
signaled | int | Non-zero if killed by signal; holds signal number |
timed_out | int | Non-zero if killed due to timeout |
stdout_buf | const char * | Captured stdout (NULL in Stream/Discard mode) |
stdout_len | size_t | Length of captured stdout |
stderr_buf | const char * | Captured stderr (NULL in Stream/Discard/PTY mode) |
stderr_len | size_t | Length of captured stderr |
elapsed_ms | uint64_t | Wall-clock duration from spawn to exit |
pty_fd | int | PTY master fd (valid while running, -1 otherwise) |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xCommandExecutorCreate | xCommandExecutor xCommandExecutorCreate(xEventLoop loop) | Create a command executor bound to the given event loop. Registers a SIGCHLD watch. | Not thread-safe |
xCommandExecutorDestroy | void xCommandExecutorDestroy(xCommandExecutor exec) | Destroy an executor. If running, kills the child process group (SIGKILL) and waits. NULL-safe. | Not thread-safe |
xCommandExecutorSubmit | xErrno xCommandExecutorSubmit(xCommandExecutor exec, const xCommandConf *conf, xCommandExecutorOutputFunc on_stdout, xCommandExecutorOutputFunc on_stderr, xCommandExecutorDoneFunc on_done, void *ud) | Submit a command for asynchronous execution. Returns xErrno_Busy if already running. | Not thread-safe (call from event loop thread) |
xCommandExecutorCancel | xErrno xCommandExecutorCancel(xCommandExecutor exec) | Cancel a running command (SIGTERM → SIGKILL after 5s). Returns xErrno_InvalidState if not running. | Not thread-safe |
xCommandExecutorPid | int xCommandExecutorPid(xCommandExecutor exec) | Return the PID of the running child, or -1 if idle. NULL-safe. | Thread-safe (atomic) |
xCommandExecutorIsRunning | int xCommandExecutorIsRunning(xCommandExecutor exec) | Return non-zero if a command is currently running. NULL-safe. | Thread-safe (atomic) |
xCommandExecutorPtyFd | int xCommandExecutorPtyFd(xCommandExecutor exec) | Return the PTY master fd, or -1 if not in PTY mode or not running. NULL-safe. | Thread-safe |
Usage Examples
Capture stdout
#include <stdio.h>
#include <x/base/command.h>
#include <x/base/event.h>
static void on_done(xCommandExecutor exec, const xCommandResult *result, void *ud) {
xEventLoop loop = (xEventLoop)ud;
if (result->exit_code == 0) {
printf("Output: %.*s\n", (int)result->stdout_len, result->stdout_buf);
}
xEventLoopStop(loop);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xCommandExecutor exec = xCommandExecutorCreate(loop);
const char *argv[] = {"hello", "world", NULL};
xCommandConf conf = {};
conf.cmd = "/bin/echo";
conf.argv = argv;
conf.stdout_mode = xCommandOutput_Capture;
conf.stderr_mode = xCommandOutput_Discard;
xCommandExecutorSubmit(exec, &conf, NULL, NULL, on_done, loop);
xEventLoopRun(loop);
xCommandExecutorDestroy(exec);
xEventLoopDestroy(loop);
return 0;
}
Stream stdout in real time
#include <stdio.h>
#include <x/base/command.h>
#include <x/base/event.h>
static void on_stdout(xCommandExecutor exec, const char *data, size_t len, void *ud) {
fwrite(data, 1, len, stdout);
}
static void on_done(xCommandExecutor exec, const xCommandResult *result, void *ud) {
xEventLoop loop = (xEventLoop)ud;
xEventLoopStop(loop);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xCommandExecutor exec = xCommandExecutorCreate(loop);
const char *argv[] = {"-c", "for i in 1 2 3; do echo line $i; done", NULL};
xCommandConf conf = {};
conf.cmd = "/bin/sh";
conf.argv = argv;
conf.stdout_mode = xCommandOutput_Stream;
conf.stderr_mode = xCommandOutput_Discard;
xCommandExecutorSubmit(exec, &conf, on_stdout, NULL, on_done, loop);
xEventLoopRun(loop);
xCommandExecutorDestroy(exec);
xEventLoopDestroy(loop);
return 0;
}
Timeout and cancellation
#include <stdio.h>
#include <x/base/command.h>
#include <x/base/event.h>
static void on_done(xCommandExecutor exec, const xCommandResult *result, void *ud) {
xEventLoop loop = (xEventLoop)ud;
if (result->timed_out) {
printf("Command timed out after %llu ms\n",
(unsigned long long)result->elapsed_ms);
}
xEventLoopStop(loop);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xCommandExecutor exec = xCommandExecutorCreate(loop);
const char *argv[] = {"60", NULL};
xCommandConf conf = {};
conf.cmd = "/bin/sleep";
conf.argv = argv;
conf.timeout_ms = 3000; /* 3-second timeout */
conf.stdout_mode = xCommandOutput_Discard;
conf.stderr_mode = xCommandOutput_Discard;
xCommandExecutorSubmit(exec, &conf, NULL, NULL, on_done, loop);
xEventLoopRun(loop);
xCommandExecutorDestroy(exec);
xEventLoopDestroy(loop);
return 0;
}
PTY mode with stdin
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <x/base/command.h>
#include <x/base/event.h>
static void on_done(xCommandExecutor exec, const xCommandResult *result, void *ud) {
xEventLoop loop = (xEventLoop)ud;
if (result->stdout_buf) {
printf("Output: %.*s\n", (int)result->stdout_len, result->stdout_buf);
}
xEventLoopStop(loop);
}
static void on_stdout(xCommandExecutor exec, const char *data, size_t len, void *ud) {
fwrite(data, 1, len, stdout);
fflush(stdout);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xCommandExecutor exec = xCommandExecutorCreate(loop);
const char *argv[] = {NULL};
xCommandConf conf = {};
conf.cmd = "/bin/cat"; /* cat echoes stdin to stdout */
conf.argv = argv;
conf.stdout_mode = xCommandOutput_Stream;
conf.stderr_mode = xCommandOutput_Discard;
conf.input_mode = xCommandInput_Pty;
xCommandExecutorSubmit(exec, &conf, on_stdout, NULL, on_done, loop);
/* Write to the child's stdin via the PTY master fd */
int pty_fd = xCommandExecutorPtyFd(exec);
if (pty_fd >= 0) {
write(pty_fd, "hello\n", 6);
}
xEventLoopRun(loop);
xCommandExecutorDestroy(exec);
xEventLoopDestroy(loop);
return 0;
}
Custom working directory and environment
#include <stdio.h>
#include <x/base/command.h>
#include <x/base/event.h>
static void on_done(xCommandExecutor exec, const xCommandResult *result, void *ud) {
xEventLoop loop = (xEventLoop)ud;
printf("Exit code: %d\n", result->exit_code);
if (result->stdout_buf) {
printf("pwd: %.*s\n", (int)result->stdout_len, result->stdout_buf);
}
xEventLoopStop(loop);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xCommandExecutor exec = xCommandExecutorCreate(loop);
const char *envp[] = {"MY_VAR=42", NULL};
xCommandConf conf = {};
conf.cmd = "/bin/pwd";
conf.cwd = "/tmp";
conf.envp = envp;
conf.stdout_mode = xCommandOutput_Capture;
conf.stderr_mode = xCommandOutput_Discard;
xCommandExecutorSubmit(exec, &conf, NULL, NULL, on_done, loop);
xEventLoopRun(loop);
xCommandExecutorDestroy(exec);
xEventLoopDestroy(loop);
return 0;
}
Use Cases
-
Shell Command Execution — Run system commands (e.g.,
git,docker, build tools) asynchronously and capture their output without blocking the event loop. -
Process Pipeline Integration — Use streaming mode to feed a child process's output into another system in real time (e.g., log aggregation, progress monitoring).
-
Interactive Programs — PTY mode enables interaction with programs that require a terminal (e.g., SSH sessions, REPLs, text editors with colored output).
-
Build/Deploy Automation — Run build scripts with timeout enforcement. If a build hangs, it is automatically killed after the configured timeout.
-
Health Checks — Periodically execute diagnostic commands and parse their output to determine system health.
Best Practices
-
Always set
on_done. The completion callback is the only way to know when a command finishes. It fires even on timeout or cancellation, so you can always clean up in one place. -
Reuse executors for sequential commands. After
on_donefires, the samexCommandExecutorcan be used for the next command. There is no need to destroy and recreate it. -
Use
stdout_cap/stderr_capto limit memory. Unbounded capture can exhaust memory if a command produces large output. Set a cap to prevent this. -
Use Discard mode when output is not needed. This avoids the overhead of reading and buffering output entirely.
-
Be aware of PTY line editing. In PTY mode, the child's terminal driver may echo input and insert
\rbefore\n. Strip\rif you need clean output. -
Don't call
xCommandExecutorSubmit()from theon_donecallback. Although the executor is idle at that point, callingxCommandExecutorSubmit()insideon_donewill start a new command immediately while the event loop is still processing I/O events from the previous one. Instead, usexEventLoopPost()to defer the next run.
Comparison with Other Libraries
| Feature | xbase command.h | popen() / pclose() | posix_spawn() | libuv uv_spawn |
|---|---|---|---|---|
| Async / Event-Loop | Yes (xEventLoop) | No (blocking) | No (blocking wait) | Yes (uv_loop) |
| stdout + stderr | Separate capture/stream | stdout only | Manual pipe setup | Separate pipes |
| Streaming | Yes (callbacks) | Line-by-line only | Manual | Yes (callbacks) |
| PTY Support | Yes (xCommandInput_Pty) | No | No | No (external) |
| Timeout | Built-in (timeout_ms) | Manual | Manual | Manual (uv_timer) |
| Cancellation | xCommandExecutorCancel() (SIGTERM→SIGKILL) | kill() + pclose() | kill() + waitpid() | uv_process_kill() |
| Process Groups | Yes (independent via setpgid) | No | No | No (manual) |
| Platform | macOS + Linux | POSIX | POSIX | Cross-platform |
Key Differentiator: xbase's command executor is deeply integrated with the event loop, providing built-in timeout, cancellation with graceful escalation, independent process groups, and PTY support — features that require significant boilerplate with lower-level APIs.
Implementation Details
Output Modes
| Mode | stdout/stderr behavior | xCommandResult fields |
|---|---|---|
xCommandOutput_Capture | Accumulate into internal buffers | stdout_buf / stderr_buf + stdout_len / stderr_len populated |
xCommandOutput_Stream | Deliver chunks via callbacks | stdout_buf / stderr_buf are NULL; use on_stdout / on_stderr callbacks |
xCommandOutput_Discard | Redirect to /dev/null | stdout_buf / stderr_buf are NULL |
Input Modes
| Mode | Description |
|---|---|
xCommandInput_Pipe | Default: stdin is inherited from the parent process (no PTY). stdout and stderr are captured/streamed separately via pipes. |
xCommandInput_Pty | Allocate a pseudo-terminal for the child. The child's stdin, stdout, and stderr are all connected to the PTY slave side. The parent reads from the PTY master fd. |
PTY mode implications:
- stdout and stderr are merged into a single stream (the PTY master).
stderr_modeis effectively ignored — there is no separate stderr stream.- In Capture mode, all output goes to
result.stdout_bufonly;result.stderr_bufis always NULL. - The
on_stderrcallback is never invoked. result.pty_fdis set to the master fd while the command is running, allowing the caller to write to the child's stdin. It is set to-1after the command completes.
Process Lifecycle
flowchart TD
SUBMIT["xCommandExecutorSubmit()"]
FORK["fork() + execvp()"]
SETPGID["setpgid() → own process group"]
RUNNING["Command running"]
CHECK_SIGCHLD{"SIGCHLD received?"}
CHECK_EXIT{"Normal exit?"}
DONE["on_done(result)"]
TIMEOUT{"Timeout expired?"}
CANCEL{"xCommandExecutorCancel()?"}
SIGTERM["killpg(SIGTERM)"]
GRACE{"Grace period (5s)"}
SIGKILL["killpg(SIGKILL)"]
SUBMIT --> FORK
FORK --> SETPGID
SETPGID --> RUNNING
RUNNING --> CHECK_SIGCHLD
CHECK_SIGCHLD -->|Yes| CHECK_EXIT
CHECK_EXIT -->|Yes| DONE
CHECK_EXIT -->|No| RUNNING
CHECK_SIGCHLD -->|No| TIMEOUT
TIMEOUT -->|No| CANCEL
CANCEL -->|No| RUNNING
TIMEOUT -->|Yes| SIGTERM
CANCEL -->|Yes| SIGTERM
SIGTERM --> GRACE
GRACE --> CHECK_EXIT
GRACE -->|"still alive"| SIGKILL
SIGKILL --> DONE
style SUBMIT fill:#4a90d9,color:#fff
style DONE fill:#50b86c,color:#fff
style SIGKILL fill:#e74c3c,color:#fff
Sequential Execution
An xCommandExecutor can only run one command at a time. Calling xCommandExecutorSubmit() while a command is running returns xErrno_Busy. After on_done fires, the executor can be reused for a new command — there is no need to destroy and recreate it.
random.h — Cross-Platform Secure Random
Introduction
random.h provides xRandomBytes(), a cross-platform function for generating cryptographically secure random bytes. It uses platform-native APIs where available (getrandom() on Linux, getentropy() on macOS, BCryptGenRandom() on Windows), falling back to /dev/urandom. This is the randomness source used by the UUID module (uuid.h) for v4 (random) and v7 (time-ordered) generation.
Design Philosophy
-
Platform-Native First — Prefers kernel-level CSPRNG APIs before falling back to file-based sources. This avoids file descriptor exhaustion in high-throughput scenarios.
-
Strict Validation — Returns
xErrno_InvalidArgifbufis NULL. No silent failures. -
Loop for Success — All backends loop on
EINTRand partial reads. The function does not return until the buffer is fully populated or a fatal error occurs. -
No External Dependencies — Pure POSIX / Win32 API. No OpenSSL, no mbedTLS, no threading.
Architecture
flowchart TD
CALL["xRandomBytes(buf, len)"]
NULL{"buf == NULL?"} -->|yes| ERR["xErrno_InvalidArg"]
NULL -->|no| LINUX{"Linux + getrandom?"}
LINUX -->|yes| GR["syscall(SYS_getrandom)"]
GR -->|success| OK["xErrno_Ok"]
GR -->|fail| FALLBACK
LINUX -->|no| MACOS{"macOS?"}
MACOS -->|yes| ENT["getentropy()"]
ENT -->|success| OK
ENT -->|fail| FALLBACK
MACOS -->|no| WIN{"Windows?"}
WIN -->|yes| BCRYPT["BCryptGenRandom()"]
BCRYPT -->|success| OK
BCRYPT -->|fail| FALLBACK
WIN -->|no| FALLBACK
FALLBACK["/dev/urandom"] -->|success| OK
FALLBACK -->|fail| ERR2["xErrno_SysError"]
style OK fill:#50b86c,color:#fff
style ERR fill:#e74c3c,color:#fff
style ERR2 fill:#e74c3c,color:#fff
API Reference
Functions
| Function | Signature | Description |
|---|---|---|
xRandomBytes | xErrno xRandomBytes(void *buf, size_t len) | Fill buf with len cryptographically secure random bytes. |
Parameters
| Parameter | Description |
|---|---|
buf | Destination buffer. Must not be NULL. |
len | Number of random bytes to generate. 0 is valid (no-op). |
Return Values
| Return | Description |
|---|---|
xErrno_Ok | Success — buf filled with len random bytes. |
xErrno_InvalidArg | buf is NULL. |
xErrno_SysError | Platform call failed (e.g., /dev/urandom unreadable). |
Usage Examples
Generate a buffer of random bytes
#include <x/base/random.h>
uint8_t buf[32];
xErrno err = xRandomBytes(buf, sizeof(buf));
if (err != xErrno_Ok) {
// Handle platform failure
}
Generate a random 64-bit integer
uint64_t val;
xRandomBytes(&val, sizeof(val));
printf("%" PRIu64 "\n", val);
Generate a random session token
uint8_t token[16];
xRandomBytes(token, sizeof(token));
// Encode as hex
char hex[33];
for (int i = 0; i < 16; i++) {
snprintf(hex + i * 2, 3, "%02x", token[i]);
}
Best Practices
- Check the return value —
xErrno_Okguarantees the buffer is fully populated. - Zero is valid —
xRandomBytes(buf, 0)is a no-op and always succeeds. - No CSRF tokens from
xRandomBytesalone — Cryptographically secure bytes are a foundation; combine with application-level token management. - Use for seeds and secrets only — This is a cryptographic-quality source. For non-security randomness, use C's
rand()or a faster PRNG.
Relationship with Other Modules
- xcrypto —
uuid.hdepends onxRandomBytesto generate the random portions of UUID v4 and v7. - xbase — Lives in xbase (no module dependencies). Uses only the error code system (
xErrno).
flag.h — Command-Line Flag Parser
Introduction
flag.h is a self-contained POSIX/GNU-style command-line parser. It replaces ad-hoc getopt(3) usage across examples and applications, producing structured values in caller-owned storage and auto-generating a usage screen. It is deliberately scoped to a single, flat flag set — subcommand trees, environment fallback, shell-completion, and long-name prefix matching are left to a future higher-level xcli module layered on top.
Design Philosophy
-
Zero-Copy, Caller-Owned Storage — Each
xFlagAdd*call takes a typed pointer (bool *,int *,const char **, …).xFlagParse()writes directly into that storage. String values point intoargvmemory, matchinggetopt'soptargconvention — no hidden allocations on the hot path. -
Never Calls
exit()— The parser returns a structuredxErrno; the caller decides what to do.--help/--versionare surfaced asxErrno_Againafter the text is printed on stdout, so applications stay in full control of their exit path. -
POSIX/GNU Syntax, Strict Matching — Short bundling (
-abc), glued values (-fvalue),--long=value,--end-of-options, and the bare-stdin idiom are all supported. Long-name prefix matching (--fifor--file) is deliberately omitted: exact match only, to keep scripts forward-compatible when new flags are added. -
Auto-Generated Help — Every flag carries a one-line description, an optional argument placeholder, and an optional default.
xFlagPrintHelp()formats a standard usage block (USAGE:line →Arguments:→Options:→ epilog) with two-column alignment. Hidden flags (xFlagAttr_Hidden) are omitted. -
Built-in Validation — Integer flags accept decimal,
0xhex,0bbinary, and0-prefixed octal, with overflow detection. Choice flags enforce a fixed whitelist and report valid values on mismatch. Required flags fail parse if absent.
Architecture
graph TD
APP["Application"]
SET["xFlagSet<br/>(registered flags)"]
PARSE["xFlagParse()"]
STORAGE["Caller Storage<br/>(bool, int, const char*, ...)"]
HELP["xFlagPrintHelp()"]
ERR["err_out (char*)"]
APP -->|xFlagSetCreate| SET
APP -->|xFlagAddString / Bool / Int / ...| SET
APP -->|xFlagParse argc/argv| PARSE
SET --> PARSE
PARSE -->|on success| STORAGE
PARSE -->|on --help| HELP
PARSE -->|on error| ERR
APP -->|use values| STORAGE
style APP fill:#4a90d9,color:#fff
style SET fill:#f5a623,color:#fff
style PARSE fill:#50b86c,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xFlagSet | Opaque handle representing a set of registered flags |
xFlagAttr | Per-flag attribute bitmask (see Flag Attributes) |
Lifecycle
| Function | Signature | Description |
|---|---|---|
xFlagSetCreate | xFlagSet xFlagSetCreate(const char *prog, const char *summary) | Create a flag set. prog is shown in usage (typically argv[0] or a fixed string); summary is an optional one-line description |
xFlagSetDestroy | void xFlagSetDestroy(xFlagSet set) | Destroy a flag set and release owned memory. NULL-safe. Does not touch caller-owned storage |
xFlagSetEpilog | void xFlagSetEpilog(xFlagSet set, const char *text) | Append an epilog section printed after the options block (e.g. "Examples:" or "Notes:"). Pass NULL to clear |
xFlagSetVersion | void xFlagSetVersion(xFlagSet set, const char *version) | Register a version string; enables --version / -V handling. Pass NULL to disable |
Scalar Flag Registration
All xFlagAdd* functions return xErrno_Ok, xErrno_InvalidArg (bad arguments), xErrno_AlreadyExists (duplicate name/shortc), or xErrno_NoMemory.
| Function | Signature | Description |
|---|---|---|
xFlagAddString | xErrno xFlagAddString(xFlagSet set, const char *name, char shortc, const char *meta, const char *help, const char **storage, const char *def, int attrs) | String flag (--url ws://... / -u ws://...) |
xFlagAddBool | xErrno xFlagAddBool(xFlagSet set, const char *name, char shortc, const char *help, bool *storage, int attrs) | Boolean switch; presence → true; takes no argument |
xFlagAddInt | xErrno xFlagAddInt(xFlagSet set, const char *name, char shortc, const char *meta, const char *help, int *storage, int def, int attrs) | Signed 32-bit integer |
xFlagAddI64 | xErrno xFlagAddI64(xFlagSet set, const char *name, char shortc, const char *meta, const char *help, int64_t *storage, int64_t def, int attrs) | Signed 64-bit integer |
xFlagAddU64 | xErrno xFlagAddU64(xFlagSet set, const char *name, char shortc, const char *meta, const char *help, uint64_t *storage, uint64_t def, int attrs) | Unsigned 64-bit integer |
xFlagAddDouble | xErrno xFlagAddDouble(xFlagSet set, const char *name, char shortc, const char *meta, const char *help, double *storage, double def, int attrs) | Double-precision float |
xFlagAddChoice | xErrno xFlagAddChoice(xFlagSet set, const char *name, char shortc, const char *meta, const char *help, const char *const *choices, const char **storage, const char *def, int attrs) | String flag restricted to a fixed whitelist. choices is a NULL-terminated array that must outlive set |
xFlagAddCounter | xErrno xFlagAddCounter(xFlagSet set, const char *name, char shortc, const char *help, int *storage, int attrs) | Counter; each occurrence increments storage by 1 (e.g. -vvv → 3). Takes no argument |
Shared parameter conventions:
| Parameter | Meaning |
|---|---|
name | Long name without dashes (e.g. "file"). May be NULL for short-only flags. Must be unique |
shortc | Single-character short name (e.g. 'f'). Pass 0 for long-only flags. Must be unique |
meta | Placeholder shown in usage (e.g. "FILE"). NULL → the flag takes no argument in usage formatting. Ignored by xFlagAddBool / xFlagAddCounter |
help | One-line description (NULL → empty) |
storage | Pointer to caller-owned variable filled on successful parse. Must outlive xFlagParse() |
def | Default value written to *storage before parsing; also shown as [default: ...] in usage |
attrs | Bitmask of xFlagAttr values |
Positional Registration
| Function | Signature | Description |
|---|---|---|
xFlagAddPositional | xErrno xFlagAddPositional(xFlagSet set, const char *name, const char *help, const char **storage, int attrs) | Register a single positional argument. Positionals are matched in registration order. Use xFlagAttr_Required to mark mandatory ones |
xFlagAddPositionalTail | xErrno xFlagAddPositionalTail(xFlagSet set, const char *name, const char *help, const char ***storage, size_t *count, int attrs) | Register a tail positional that captures all remaining argv after previously-registered positionals. Only one tail is allowed, and it must be registered last. The resulting NUL-terminated array is owned by the set |
Parse & Output
| Function | Signature | Description |
|---|---|---|
xFlagParse | xErrno xFlagParse(xFlagSet set, int argc, char *const argv[], char **err_out) | Parse argv and populate every registered storage pointer. Returns xErrno_Ok on success, xErrno_Again if --help or --version was handled (text already printed to stdout), xErrno_InvalidArg on bad input (*err_out filled with a one-line message the caller must free()), or xErrno_NoMemory. Never calls exit() |
xFlagPrintUsage | void xFlagPrintUsage(xFlagSet set, void *fp) | Print the USAGE: ... summary line to fp (typically stdout or stderr; typed as void * to keep <stdio.h> out of the header) |
xFlagPrintHelp | void xFlagPrintHelp(xFlagSet set, void *fp) | Print the full help screen (usage + arguments + options + epilog) to fp |
Usage Examples
Minimal boolean + string flag
#include <stdio.h>
#include <stdlib.h>
#include <x/base/flag.h>
int main(int argc, char *argv[]) {
xFlagSet set = xFlagSetCreate("demo", "a tiny example");
bool ipv6 = false;
const char *url = NULL;
xFlagAddBool (set, "ipv6", '6', "enable IPv6", &ipv6, xFlagAttr_None);
xFlagAddString(set, "url", 'u', "URL", "signal server",
&url, "ws://127.0.0.1:8080/ws", xFlagAttr_None);
char *err = NULL;
xErrno rc = xFlagParse(set, argc, argv, &err);
if (rc == xErrno_Again) { xFlagSetDestroy(set); return 0; }
if (rc != xErrno_Ok) {
fprintf(stderr, "%s\n", err ? err : "parse error");
free(err);
xFlagSetDestroy(set);
return 1;
}
printf("ipv6 = %s, url = %s\n", ipv6 ? "true" : "false", url);
xFlagSetDestroy(set);
return 0;
}
Integer, counter and choice
#include <stdio.h>
#include <stdlib.h>
#include <x/base/flag.h>
int main(int argc, char *argv[]) {
xFlagSet set = xFlagSetCreate("srv", "demo server");
int port = 0;
int verbose = 0; /* -vvv → 3 */
const char *level = NULL; /* one of debug/info/warn/error */
static const char *const levels[] = {
"debug", "info", "warn", "error", NULL,
};
xFlagAddInt (set, "port", 'p', "PORT", "listen port",
&port, 8080, xFlagAttr_None);
xFlagAddCounter(set, "verbose", 'v', "increase verbosity",
&verbose, xFlagAttr_None);
xFlagAddChoice (set, "level", 'l', "LEVEL", "log level",
levels, &level, "info", xFlagAttr_None);
char *err = NULL;
xErrno rc = xFlagParse(set, argc, argv, &err);
if (rc == xErrno_Again) { xFlagSetDestroy(set); return 0; }
if (rc != xErrno_Ok) {
fprintf(stderr, "%s\n", err ? err : "parse error");
free(err);
xFlagSetDestroy(set);
return 1;
}
printf("port=%d verbose=%d level=%s\n", port, verbose, level);
xFlagSetDestroy(set);
return 0;
}
Invocation examples that all succeed:
srv --port 9000 -vvv --level=debug
srv -p 0x1f90 -v -v -v -l debug
srv # uses defaults: port=8080 verbose=0 level=info
Positional arguments and a tail
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <x/base/flag.h>
int main(int argc, char *argv[]) {
xFlagSet set = xFlagSetCreate("tar", "mini tar(1)");
const char *archive = NULL;
const char **members = NULL;
size_t n = 0;
/* Positionals are matched in registration order.
* Layout on the command line: tar ARCHIVE MEMBERS...
* So register ARCHIVE first, then the MEMBERS tail. */
xFlagAddPositional (set, "ARCHIVE", "archive path", &archive,
xFlagAttr_Required);
xFlagAddPositionalTail(set, "MEMBERS", "files to add", &members, &n,
xFlagAttr_None);
char *err = NULL;
xErrno rc = xFlagParse(set, argc, argv, &err);
if (rc == xErrno_Again) { xFlagSetDestroy(set); return 0; }
if (rc != xErrno_Ok) {
fprintf(stderr, "%s\n", err ? err : "parse error");
free(err);
xFlagSetDestroy(set);
return 1;
}
printf("archive = %s\n", archive);
for (size_t i = 0; i < n; ++i) printf(" + %s\n", members[i]);
xFlagSetDestroy(set);
return 0;
}
Note: positionals are matched in the order they are registered, and a tail positional must be registered last. A trailing required positional after a tail (e.g.
cp SRC... DST) is not supported in v1 — you would need to consume the last element manually after parsing, or skip the tail and iterateargvyourself.
Handling -- and stdin shorthand
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <x/base/flag.h>
int main(int argc, char *argv[]) {
xFlagSet set = xFlagSetCreate("grep", "tiny grep");
bool invert = false;
const char *pattern = NULL;
const char **files = NULL;
size_t nfiles = 0;
xFlagAddBool (set, "invert", 'v', "invert match", &invert,
xFlagAttr_None);
xFlagAddPositional (set, "PATTERN", "regex", &pattern,
xFlagAttr_Required);
xFlagAddPositionalTail(set, "FILE", "input files (use - for stdin)",
&files, &nfiles, xFlagAttr_None);
char *err = NULL;
xErrno rc = xFlagParse(set, argc, argv, &err);
if (rc == xErrno_Again) { xFlagSetDestroy(set); return 0; }
if (rc != xErrno_Ok) {
fprintf(stderr, "%s\n", err ? err : "parse error");
free(err);
xFlagSetDestroy(set);
return 1;
}
/* `grep -- -v foo.txt` treats "-v" as the PATTERN (positional),
* because "--" ends option parsing.
* `grep foo -` leaves files = {"-"} so the caller reads from stdin. */
xFlagSetDestroy(set);
return 0;
}
Generated help screen
With the flags from the "Integer, counter and choice" example plus xFlagSetVersion(set, "1.2.3"), running srv --help prints something like:
srv - demo server
USAGE: srv [OPTIONS]
Options:
-p, --port PORT listen port [default: 8080]
-v, --verbose increase verbosity
-l, --level LEVEL log level (one of: debug, info, warn, error) [default: info]
-V, --version show version
-h, --help show this help
Use Cases
-
Example / Demo Programs — Replace
getopt_long()boilerplate inexamples/with a fewxFlagAdd*calls and get a formatted help screen for free. -
CLI Tools — Small libx-based utilities (benchmarks, migration scripts, diagnostic tools) that want conventional POSIX/GNU syntax without pulling in
argpor a heavyweight parser. -
Application Front-Ends — Projects under
cli/that wrap libx modules into standalone binaries can useflag.hfor their startup configuration, and later upgrade toxclionce subcommand trees are needed. -
Configuration Overrides — Parse command-line overrides before loading a config file;
xFlagAttr_Requiredmarks mandatory knobs and[default: ...]documents the rest in--help.
Best Practices
-
Always handle
xErrno_Again. This signals that--help/--versionwas processed. The parser has already written to stdout; the caller should exit0cleanly. -
free()the error string. On failure,*err_outis heap-allocated. Forgetting to free leaks one string per failed invocation — minor, but tools like leak sanitisers will flag it. -
strdup()strings you need to outlivemain. Parsed string values point intoargv. If you stash them into a long-lived config struct, copy them. -
Register positionals last, tail last of all. Long flags and short flags can be registered in any order, but positionals are matched in registration order, and a tail positional must come at the end.
-
Prefer
xFlagAddChoiceover free-form strings. The parser does the enum validation for you and shows the allowed values in--help, saving you astrcmpladder and giving users a self-documenting interface. -
Don't depend on prefix matching.
--filwill not match--file. This is deliberate — scripts that relied on a prefix would silently break when a new flag with the same prefix is added. -
Use
xFlagAttr_Hiddensparingly. Reserve it for internal / debug / deprecated flags. A hidden flag that users need to discover is a support-channel footgun.
Comparison with Other Parsers
| Feature | xbase flag.h | getopt(3) | getopt_long(3) | argp (glibc) |
|---|---|---|---|---|
| POSIX short / GNU long | Both | Short only | Both | Both |
Auto-generated --help | Yes | No | No | Yes |
Typed storage (bool, int, …) | Yes | No (string only) | No (string only) | Partial (via parser fn) |
| Choice validation | Yes | No | No | Manual |
Counter flags (-vvv) | Built-in | Manual | Manual | Manual |
| Default values in help | Yes | No | No | No |
| Positional + tail support | Yes | Manual | Manual | Via parser fn |
Never calls exit() | Yes | Yes | Yes | No (default handlers) |
| Subcommand trees | No (future xcli) | No | No | Yes |
| Environment / config fallback | No | No | No | No |
| Platform | macOS + Linux | POSIX | GNU | glibc |
Key Differentiator: flag.h gives you argp-class ergonomics (typed storage, auto-help, validation) in a header-plus-.c pair that is portable across macOS and Linux, without exit()-by-default behaviour or glibc dependencies.
Implementation Details
Supported Syntax
| Form | Meaning |
|---|---|
-f value | Short flag with a separate argument |
-fvalue | Short flag with a glued argument |
-abc | Bundled no-arg shorts; the last one may take an argument |
--file value | Long flag with a separate argument |
--file=value | Long flag with an =-form argument |
--flag | Long boolean or counter |
-- | End-of-options; everything after is positional |
- | Treated as a positional argument (stdin idiom) |
Not Supported (by design, in v1)
- Subcommand trees (deferred to a future
xclimodule) - Environment / config-file fallback
- Shell-completion generation
- Long-name prefix matching (
--fifor--file): exact match required - i18n
- Dynamic registration after
xFlagParse()has started
Flag Attributes
xFlagAttr is a bitmask passed as the final argument to every xFlagAdd* call.
| Attribute | Meaning |
|---|---|
xFlagAttr_None | Default (no attribute) |
xFlagAttr_Required | Parse fails with xErrno_InvalidArg if the flag is absent |
xFlagAttr_Hidden | Omit from --help output (useful for internal/debug flags) |
xFlagAttr_Multi | Allow repetition; each occurrence is collected into an internal array. Only meaningful for string flags |
Help / Version Handling
--help/-hare always recognised (unless the caller has already registeredh).--version/-Vare recognised only afterxFlagSetVersion()has been called (and only if those names are free).- Both cause
xFlagParse()to print to stdout and returnxErrno_Again. No flag storage is written.
Integer Parsing
xFlagAddInt / xFlagAddI64 / xFlagAddU64 accept:
| Prefix | Base |
|---|---|
0x / 0X | Hexadecimal (e.g. -n 0xff) |
0b / 0B | Binary (e.g. -n 0b1010) |
0 + digit | Octal (e.g. -n 0755) |
| (anything else) | Decimal |
Overflow or trailing garbage produces xErrno_InvalidArg with a descriptive err_out.
Memory Ownership
Owned by xFlagSet (freed on xFlagSetDestroy) | Owned by caller |
|---|---|
Copies of every name, help, meta, def, summary, prog, epilog string | Storage pointers (bool *, const char **, …) |
Arrays collected for xFlagAttr_Multi | choices array for xFlagAddChoice (must outlive the set) |
Tail positional array allocated by xFlagAddPositionalTail | argv itself (used zero-copy for string values) |
Error string written to *err_out | (the caller must free() *err_out) |
Parsed string values point into argv. If you need them to outlive main's argv, strdup() them.
xbuf — Buffer Toolkit
Introduction
xbuf is libx's buffer module, providing three distinct buffer types optimized for different use cases: a linear auto-growing buffer, a fixed-size ring buffer, and a reference-counted block-chain I/O buffer. Together they cover the full spectrum of buffering needs — from simple byte accumulation to zero-copy network I/O.
Design Philosophy
-
One Buffer Does Not Fit All — Rather than a single "universal" buffer, xbuf offers three specialized types. Each makes different trade-offs between simplicity, performance, and memory efficiency.
-
Flexible Array Member Layout — Both
xBufferandxRingBufferallocate header + data in a singlemalloc()call using C99 flexible array members. This eliminates pointer indirection and improves cache locality. -
Reference-Counted Block Sharing —
xIOBufferuses reference-counted blocks that can be shared across multiple buffers. This enables zero-copy split and append operations critical for high-performance network protocols. -
I/O Integration — All three types provide
ReadFd/WriteFdhelpers that handleEINTRretries and scatter-gather I/O (readv/writev), making them ready for event-driven network programming.
Architecture
graph TD
subgraph "xbuf Module"
BUF["xBuffer<br/>Linear auto-growing<br/>Single contiguous allocation"]
RING["xRingBuffer<br/>Fixed-size circular<br/>Power-of-2 masking"]
IO["xIOBuffer<br/>Block-chain<br/>Reference-counted"]
end
subgraph "Shared Infrastructure"
POOL["Block Pool<br/>Treiber stack freelist"]
ATOMIC["xbase/atomic.h<br/>Lock-free operations"]
end
IO --> POOL
POOL --> ATOMIC
subgraph "I/O Layer"
READ["read() / readv()"]
WRITE["write() / writev()"]
end
BUF --> READ
BUF --> WRITE
RING --> READ
RING --> WRITE
IO --> READ
IO --> WRITE
style BUF fill:#4a90d9,color:#fff
style RING fill:#f5a623,color:#fff
style IO fill:#50b86c,color:#fff
Sub-Module Overview
| Header | Type | Description | Doc |
|---|---|---|---|
buf.h | xBuffer | Linear auto-growing byte buffer with flexible array member layout | buf.md |
ring.h | xRingBuffer | Fixed-size circular buffer with power-of-2 bitmask indexing | ring.md |
io.h | xIOBuffer | Reference-counted block-chain I/O buffer with zero-copy operations | io.md |
How to Choose
| Criterion | xBuffer | xRingBuffer | xIOBuffer |
|---|---|---|---|
| Memory layout | Contiguous | Contiguous (circular) | Non-contiguous (block chain) |
| Growth | Auto-growing (2x realloc) | Fixed size (never grows) | Auto-growing (new blocks) |
| Best for | Accumulating variable-length data | Fixed-capacity producer-consumer | High-throughput network I/O |
| Zero-copy split | No | No | Yes |
| Zero-copy append | No | No | Yes (between xIOBuffers) |
| Scatter-gather I/O | No (single buffer) | Yes (up to 2 iovecs) | Yes (N iovecs) |
| Memory overhead | Minimal (1 allocation) | Minimal (1 allocation) | Per-block overhead + ref array |
| Thread safety | Not thread-safe | Not thread-safe | Block pool is thread-safe |
Decision Guide
Need to accumulate data of unknown size?
→ xBuffer (simple, auto-growing)
Need a fixed-capacity FIFO between producer and consumer?
→ xRingBuffer (no allocation after creation)
Need zero-copy operations or scatter-gather I/O for networking?
→ xIOBuffer (block-chain with reference counting)
Quick Start
#include <stdio.h>
#include <x/buf/buf.h>
#include <x/buf/ring.h>
#include <x/buf/io.h>
int main(void) {
// 1. Linear buffer: accumulate data
xBuffer buf = xBufferCreate(256);
xBufferAppend(&buf, "Hello, ", 7);
xBufferAppend(&buf, "xbuf!", 5);
printf("buf: %.*s\n", (int)xBufferLen(buf), (const char *)xBufferData(buf));
xBufferDestroy(buf);
// 2. Ring buffer: fixed-capacity FIFO
xRingBuffer ring = xRingBufferCreate(1024);
xRingBufferWrite(ring, "circular", 8);
char out[16];
size_t n = xRingBufferRead(ring, out, sizeof(out));
printf("ring: %.*s\n", (int)n, out);
xRingBufferDestroy(ring);
// 3. IO buffer: block-chain with zero-copy
xIOBuffer io;
xIOBufferInit(&io);
xIOBufferAppend(&io, "block-chain I/O", 15);
char linear[64];
xIOBufferCopyTo(&io, linear);
printf("io: %.*s\n", (int)xIOBufferLen(&io), linear);
xIOBufferDeinit(&io);
return 0;
}
Relationship with Other Modules
- xbase —
xIOBufferusesatomic.hfor lock-free block pool management and reference counting. - xhttp — The HTTP client (
client.h) usesxIOBufferfor response body accumulation and SSE stream parsing. - xlog — The async logger (
logger.h) may usexBufferfor log message formatting.
buf.h — Linear Auto-Growing Buffer
Introduction
buf.h provides xBuffer, a simple contiguous byte buffer that automatically grows when more space is needed. It maintains separate read and write positions, supporting efficient append-and-consume patterns. The buffer header and data area are allocated in a single malloc() call using a C99 flexible array member, avoiding an extra pointer indirection.
Design Philosophy
-
Single Allocation — Header and data live in one contiguous block (
struct + flexible array member). This means onemalloc(), onefree(), and excellent cache locality. -
Handle Indirection — Because
realloc()may relocate the entire object, write APIs takexBuffer *bufp(pointer to handle) so the caller's handle stays valid after growth. -
Compact Before Grow — When the buffer needs more space, it first tries to compact (slide unread data to the front) before resorting to
realloc(). This reclaims consumed space without allocation. -
2x Growth — When reallocation is necessary, capacity doubles each time, providing amortized O(1) append.
Architecture
graph LR
subgraph "xBuffer Lifecycle"
CREATE["xBufferCreate(cap)"] --> USE["Append / Read / Consume"]
USE --> GROW{"Need more space?"}
GROW -->|Compact| USE
GROW -->|Realloc 2x| USE
USE --> DESTROY["xBufferDestroy()"]
end
style CREATE fill:#4a90d9,color:#fff
style DESTROY fill:#e74c3c,color:#fff
API Reference
Lifecycle
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xBufferCreate | xBuffer xBufferCreate(size_t initial_cap) | Create a buffer. Min capacity is 64. | Not thread-safe |
xBufferDestroy | void xBufferDestroy(xBuffer buf) | Free the buffer. NULL is a no-op. | Not thread-safe |
xBufferReset | void xBufferReset(xBuffer buf) | Discard all data, keep memory. | Not thread-safe |
Write
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xBufferAppend | xErrno xBufferAppend(xBuffer *bufp, const void *data, size_t len) | Append bytes, growing if needed. | Not thread-safe |
xBufferAppendStr | xErrno xBufferAppendStr(xBuffer *bufp, const char *str) | Append a C string (excluding NUL). | Not thread-safe |
xBufferReserve | xErrno xBufferReserve(xBuffer *bufp, size_t additional) | Ensure at least additional writable bytes. | Not thread-safe |
Read
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xBufferData | const void *xBufferData(xBuffer buf) | Pointer to readable data. Valid until next mutation. | Not thread-safe |
xBufferLen | size_t xBufferLen(xBuffer buf) | Number of readable bytes. | Not thread-safe |
xBufferCap | size_t xBufferCap(xBuffer buf) | Total allocated capacity. | Not thread-safe |
xBufferWritable | size_t xBufferWritable(xBuffer buf) | Writable bytes (cap - wpos). | Not thread-safe |
xBufferConsume | void xBufferConsume(xBuffer buf, size_t n) | Advance read position by n bytes. | Not thread-safe |
xBufferCompact | void xBufferCompact(xBuffer buf) | Move unread data to front, maximize writable space. | Not thread-safe |
I/O Helpers
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xBufferReadFd | ssize_t xBufferReadFd(xBuffer *bufp, int fd) | Read from fd into buffer (ensures 4KB space). | Not thread-safe |
xBufferWriteFd | ssize_t xBufferWriteFd(xBuffer buf, int fd) | Write readable data to fd, consume written bytes. | Not thread-safe |
Usage Examples
Basic Append and Read
#include <stdio.h>
#include <x/buf/buf.h>
int main(void) {
xBuffer buf = xBufferCreate(256);
// Append data
xBufferAppend(&buf, "Hello, ", 7);
xBufferAppendStr(&buf, "World!");
// Read data
printf("Content: %.*s\n", (int)xBufferLen(buf),
(const char *)xBufferData(buf));
// Output: Content: Hello, World!
// Consume partial data
xBufferConsume(buf, 7);
printf("After consume: %.*s\n", (int)xBufferLen(buf),
(const char *)xBufferData(buf));
// Output: After consume: World!
// Compact to reclaim consumed space
xBufferCompact(buf);
xBufferDestroy(buf);
return 0;
}
Network I/O
#include <x/buf/buf.h>
#include <unistd.h>
void handle_connection(int sockfd) {
xBuffer buf = xBufferCreate(4096);
// Read from socket
ssize_t n = xBufferReadFd(&buf, sockfd);
if (n > 0) {
// Process data...
// Write response back
xBufferAppendStr(&buf, "HTTP/1.1 200 OK\r\n\r\n");
xBufferWriteFd(buf, sockfd);
}
xBufferDestroy(buf);
}
Use Cases
-
HTTP Response Accumulation — Accumulate response body chunks of unknown total size. The auto-growing behavior handles variable-length responses.
-
Protocol Parsing — Append incoming data, parse complete messages from the front, consume parsed bytes. The compact operation reclaims space without reallocation.
-
Log Message Formatting — Build log messages incrementally with multiple append calls before flushing.
Best Practices
- Always pass
&bufto write APIs. Functions that may grow the buffer takexBuffer *bufpbecauserealloc()may relocate the object. - Call
xBufferCompact()periodically if you consume data incrementally. This avoids unnecessary reallocation by reclaiming consumed space. - Check return values.
xBufferAppend()andxBufferReserve()returnxErrno_NoMemoryon allocation failure. - Don't cache
xBufferData()pointers across mutating calls. Any append/reserve/compact may invalidate the pointer.
Comparison with Other Libraries
| Feature | xbuf buf.h | Go bytes.Buffer | Rust Vec<u8> | C++ std::vector<char> |
|---|---|---|---|---|
| Layout | Header + data in one allocation (FAM) | Separate header + slice | Heap-allocated array | Heap-allocated array |
| Growth | 2x realloc + compact | 2x (with copy) | 2x (with copy) | Implementation-defined |
| Read/Write cursors | Yes (rpos/wpos) | Yes (read offset) | No (manual tracking) | No (manual tracking) |
| Compact | Built-in (xBufferCompact) | Built-in (implicit) | Manual | Manual |
| I/O helpers | ReadFd/WriteFd | ReadFrom/WriteTo | Via Read/Write traits | No |
| Handle invalidation | Caller updates via *bufp | GC handles | Borrow checker | Iterator invalidation |
Key Differentiator: xBuffer's single-allocation layout (flexible array member) eliminates one level of pointer indirection compared to typical buffer implementations. The compact-before-grow strategy minimizes reallocation frequency for append-consume workloads.
Benchmark
Environment: Apple M3 Pro, 36 GB RAM, macOS 26.4, Release build (
-O2). Source:xbuf/buf_bench.cpp
| Benchmark | Chunk Size | Time (ns) | CPU (ns) | Throughput |
|---|---|---|---|---|
BM_Buffer_Append | 16 | 4,776 | 4,776 | 3.1 GiB/s |
BM_Buffer_Append | 64 | 4,400 | 4,400 | 13.5 GiB/s |
BM_Buffer_Append | 256 | 7,892 | 7,892 | 30.2 GiB/s |
BM_Buffer_Append | 1,024 | 21,834 | 21,811 | 43.7 GiB/s |
BM_Buffer_Append | 4,096 | 91,029 | 90,958 | 41.9 GiB/s |
BM_Buffer_AppendConsume | 64 | 4,999 | 4,999 | 11.9 GiB/s |
BM_Buffer_AppendConsume | 256 | 8,241 | 8,240 | 28.9 GiB/s |
BM_Buffer_AppendConsume | 1,024 | 22,859 | 22,859 | 41.7 GiB/s |
Key Observations:
- Append throughput peaks at ~44 GiB/s for 1KB chunks, limited by
memcpybandwidth and reallocation overhead. - AppendConsume (interleaved append + consume) achieves comparable throughput to pure append, validating the compact-before-grow strategy — consumed space is reclaimed without reallocation.
- Small chunks (16B) show lower throughput due to per-call overhead dominating the
memcpycost.
Implementation Details
Memory Layout
Single malloc() allocation:
┌──────────────────┬──────────────────────────────────────────┐
│ xBuffer_ header │ data[cap] (flexible array member) │
│ rpos, wpos, cap │ │
└──────────────────┴──────────────────────────────────────────┘
↑ ↑ ↑
data+rpos data+wpos data+cap
│←readable→│←────writable──────→│
Internal Structure
XDEF_STRUCT(xBuffer_) {
size_t rpos; // Read position (start of unread data)
size_t wpos; // Write position (end of unread data)
size_t cap; // Total data capacity
char data[]; // Flexible array member
};
Growth Strategy
flowchart TD
APPEND["xBufferAppend(bufp, data, len)"]
CHECK{"wpos + len <= cap?"}
WRITE["memcpy at wpos, advance wpos"]
COMPACT{"rpos > 0 AND<br/>unread + len <= cap?"}
MEMMOVE["memmove data to front<br/>rpos=0, wpos=unread"]
REALLOC["realloc(cap * 2)"]
UPDATE["Update *bufp"]
APPEND --> CHECK
CHECK -->|Yes| WRITE
CHECK -->|No| COMPACT
COMPACT -->|Yes| MEMMOVE --> WRITE
COMPACT -->|No| REALLOC --> UPDATE --> WRITE
style WRITE fill:#50b86c,color:#fff
style REALLOC fill:#f5a623,color:#fff
Operations and Complexity
| Operation | Time Complexity | Notes |
|---|---|---|
xBufferAppend | Amortized O(1) per byte | May trigger compact or realloc |
xBufferConsume | O(1) | Advances read position |
xBufferCompact | O(n) | memmove of unread data |
xBufferData | O(1) | Returns data + rpos |
xBufferLen | O(1) | Returns wpos - rpos |
xBufferReadFd | O(1) | Single read() syscall |
xBufferWriteFd | O(1) | Single write() syscall |
ring.h — Fixed-Size Ring Buffer
Introduction
ring.h provides xRingBuffer, a fixed-capacity circular buffer that never reallocates. It is ideal for bounded producer-consumer scenarios where a fixed memory budget is required. The capacity is rounded up to the next power of two internally, enabling bitmask indexing instead of expensive modulo operations.
Design Philosophy
-
Fixed Capacity, Zero Reallocation — Once created, the ring buffer never grows. Writes that exceed capacity are truncated to the available space (partial write). This makes memory usage predictable and avoids allocation latency spikes.
-
Power-of-Two Masking — The internal capacity is always a power of two. Index computation uses
head & maskinstead ofhead % cap, which is significantly faster on most architectures. -
Monotonic Cursors —
head(write) andtail(read) grow monotonically and never wrap. The actual array index is computed via bitmask. This simplifies the full/empty distinction:head - tailgives the exact readable byte count. -
Single Allocation — Like
xBuffer, the header and data area are allocated together using a flexible array member. -
Scatter-Gather I/O — The ring buffer provides
ReadIov/WriteIovhelpers that filliovecarrays for efficientreadv()/writev()syscalls, handling the wrap-around transparently.
Architecture
graph LR
PRODUCER["Producer"] -->|"xRingBufferWrite"| RB["xRingBuffer<br/>(fixed capacity)"]
RB -->|"xRingBufferRead"| CONSUMER["Consumer"]
RB -->|"xRingBufferReadIov"| IOV1["iovec[2]"] -->|"writev()"| FD1["fd"]
FD2["fd"] -->|"readv()"| IOV2["iovec[2]"] -->|"xRingBufferWriteIov"| RB
style RB fill:#f5a623,color:#fff
API Reference
Lifecycle
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xRingBufferCreate | xRingBuffer xRingBufferCreate(size_t min_cap) | Create a ring buffer. Capacity rounded up to power of 2. | Not thread-safe |
xRingBufferDestroy | void xRingBufferDestroy(xRingBuffer rb) | Free the ring buffer. NULL is a no-op. | Not thread-safe |
xRingBufferReset | void xRingBufferReset(xRingBuffer rb) | Discard all data, keep memory. | Not thread-safe |
Query
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xRingBufferLen | size_t xRingBufferLen(xRingBuffer rb) | Readable bytes. | Not thread-safe |
xRingBufferCap | size_t xRingBufferCap(xRingBuffer rb) | Total capacity. | Not thread-safe |
xRingBufferWritable | size_t xRingBufferWritable(xRingBuffer rb) | Writable bytes. | Not thread-safe |
xRingBufferEmpty | bool xRingBufferEmpty(xRingBuffer rb) | True if no readable data. | Not thread-safe |
xRingBufferFull | bool xRingBufferFull(xRingBuffer rb) | True if no writable space. | Not thread-safe |
Write
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xRingBufferWrite | size_t xRingBufferWrite(xRingBuffer rb, const void *data, size_t len) | Write bytes. Returns number of bytes actually written (partial write if full). | Not thread-safe |
Read
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xRingBufferRead | size_t xRingBufferRead(xRingBuffer rb, void *out, size_t len) | Read and consume bytes. Returns actual count. | Not thread-safe |
xRingBufferPeek | size_t xRingBufferPeek(xRingBuffer rb, void *out, size_t len) | Read without consuming. | Not thread-safe |
xRingBufferDiscard | size_t xRingBufferDiscard(xRingBuffer rb, size_t n) | Discard bytes without copying. | Not thread-safe |
I/O Helpers
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xRingBufferReadIov | int xRingBufferReadIov(xRingBuffer rb, struct iovec iov[2]) | Fill iovecs with readable regions (for writev). | Not thread-safe |
xRingBufferWriteIov | int xRingBufferWriteIov(xRingBuffer rb, struct iovec iov[2]) | Fill iovecs with writable regions (for readv). | Not thread-safe |
xRingBufferReadFd | ssize_t xRingBufferReadFd(xRingBuffer rb, int fd) | Read from fd using readv(). | Not thread-safe |
xRingBufferWriteFd | ssize_t xRingBufferWriteFd(xRingBuffer rb, int fd) | Write to fd using writev(). | Not thread-safe |
Usage Examples
Basic FIFO
#include <stdio.h>
#include <x/buf/ring.h>
int main(void) {
// Request 1000 bytes; actual capacity will be 1024 (next power of 2)
xRingBuffer rb = xRingBufferCreate(1000);
printf("Capacity: %zu\n", xRingBufferCap(rb)); // 1024
// Write data
const char *msg = "Hello, Ring!";
xRingBufferWrite(rb, msg, 12);
// Read data
char out[32];
size_t n = xRingBufferRead(rb, out, sizeof(out));
printf("Read %zu bytes: %.*s\n", n, (int)n, out);
xRingBufferDestroy(rb);
return 0;
}
Network Socket Buffer
#include <x/buf/ring.h>
void event_loop_handler(int sockfd) {
xRingBuffer rb = xRingBufferCreate(65536); // 64KB ring
// Read from socket into ring buffer
ssize_t n = xRingBufferReadFd(rb, sockfd);
if (n > 0) {
// Process data...
// Write processed data back
xRingBufferWriteFd(rb, sockfd);
}
xRingBufferDestroy(rb);
}
Use Cases
-
Fixed-Budget Network Buffers — When you need predictable memory usage per connection (e.g., 64KB per socket), the ring buffer provides a hard capacity limit.
-
Logging Ring Buffer — Capture the last N bytes of log output, automatically discarding old data when the buffer wraps.
-
Inter-Thread Communication — With external synchronization, a ring buffer can serve as a bounded channel between producer and consumer threads.
Best Practices
- Choose capacity carefully. The ring buffer never grows. If you write more than the available space, only a partial write is performed. Size it for your worst-case scenario.
- Use scatter-gather I/O.
xRingBufferReadFd/WriteFdusereadv()/writev()to handle wrap-around in a single syscall, avoiding the need to linearize data. - Be aware of power-of-two rounding. Requesting 1000 bytes gives you 1024. Requesting 1025 gives you 2048. Plan accordingly.
- Check the return value of
xRingBufferWrite()to detect partial writes and handle back-pressure.
Comparison with Other Libraries
| Feature | xbuf ring.h | Linux kfifo | Boost circular_buffer | DPDK rte_ring |
|---|---|---|---|---|
| Capacity | Fixed, power-of-2 | Fixed, power-of-2 | Fixed, any size | Fixed, power-of-2 |
| Indexing | Bitmask | Bitmask | Modulo | Bitmask |
| Layout | FAM (single alloc) | Separate alloc | Heap array | Huge pages |
| Thread Safety | Not thread-safe | Single-producer/single-consumer | Not thread-safe | Multi-producer/multi-consumer |
| I/O Helpers | readv/writev | kfifo_to_user/kfifo_from_user | No | No (packet-oriented) |
| Language | C99 | C (kernel) | C++ | C |
Key Differentiator: xbuf's ring buffer combines the power-of-two bitmask optimization (like kfifo) with scatter-gather I/O helpers (readv/writev) in a single-allocation design. It's purpose-built for event-driven network programming where fixed memory budgets and efficient syscalls are essential.
Benchmark
Environment: Apple M3 Pro, 36 GB RAM, macOS 26.4, Release build (
-O2). Source:xbuf/ring_bench.cpp
| Benchmark | Size | Time (ns) | CPU (ns) | Throughput |
|---|---|---|---|---|
BM_Ring_WriteRead | 64 | 6.05 | 6.05 | 19.7 GiB/s |
BM_Ring_WriteRead | 256 | 16.8 | 16.8 | 28.4 GiB/s |
BM_Ring_WriteRead | 1,024 | 27.4 | 27.4 | 69.6 GiB/s |
BM_Ring_WriteRead | 4,096 | 99.2 | 99.2 | 76.9 GiB/s |
BM_Ring_Throughput | 4,096 | 225 | 225 | 17.0 GiB/s |
BM_Ring_Throughput | 16,384 | 806 | 806 | 18.9 GiB/s |
BM_Ring_Throughput | 65,536 | 3,198 | 3,198 | 19.1 GiB/s |
Key Observations:
- WriteRead (single write + read cycle) achieves up to ~77 GiB/s at 4KB chunks, demonstrating the efficiency of the bitmask-based wrap-around and
memcpyfor larger transfers. - Throughput (sustained writes until full) stabilizes at ~19 GiB/s regardless of capacity, showing consistent performance as the ring scales.
- The ring buffer's zero-overhead indexing (bitmask instead of modulo) keeps per-operation cost extremely low — just 6ns for a 64-byte write+read cycle.
Implementation Details
Memory Layout
Single malloc() allocation:
┌───────────────────────┬──────────────────────────────────────┐
│ xRingBuffer_ header │ data[cap] (flexible array member) │
│ cap, mask, head, tail│ │
└───────────────────────┴──────────────────────────────────────┘
Circular data layout (cap=8, mask=7):
tail & mask head & mask
↓ ↓
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ │ │ R │ R │ R │ W │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
0 1 2 3 4 5 6 7
R = readable data (tail..head)
W = next write position
Internal Structure
XDEF_STRUCT(xRingBuffer_) {
size_t cap; // Capacity (power of two)
size_t mask; // cap - 1 (for bitmask indexing)
size_t head; // Write cursor (monotonic)
size_t tail; // Read cursor (monotonic)
char data[];// Flexible array member
};
Power-of-Two Rounding
static size_t next_pow2(size_t v) {
if (v < 16) v = 16;
v--;
v |= v >> 1;
v |= v >> 2;
v |= v >> 4;
v |= v >> 8;
v |= v >> 16;
// v |= v >> 32; (on 64-bit)
return v + 1;
}
This ensures cap is always a power of two, so mask = cap - 1 produces a valid bitmask. For example, cap = 8 → mask = 0b111.
Bitmask Indexing
Instead of:
size_t idx = head % cap; // Expensive division
The ring buffer uses:
size_t idx = head & mask; // Single AND instruction
This works because cap is a power of two: x % (2^n) == x & (2^n - 1).
Wrap-Around Write
flowchart TD
WRITE["xRingBufferWrite(rb, data, len)"]
CHECK{"len <= writable?"}
CLAMP["len = writable"]
POS["pos = head & mask"]
FIRST["first = cap - pos"]
WRAP{"len <= first?"}
SINGLE["memcpy(data+pos, src, len)"]
SPLIT["memcpy(data+pos, src, first)<br/>memcpy(data, src+first, len-first)"]
ADVANCE["head += len<br/>return len"]
ZERO["return 0"]
WRITE --> CHECK
CHECK -->|No| CLAMP --> POS
CHECK -->|Yes| POS
CHECK -->|writable == 0| ZERO
POS --> FIRST --> WRAP
WRAP -->|Yes| SINGLE --> ADVANCE
WRAP -->|No| SPLIT --> ADVANCE
style ZERO fill:#e74c3c,color:#fff
style ADVANCE fill:#50b86c,color:#fff
Operations and Complexity
| Operation | Time Complexity | Notes |
|---|---|---|
xRingBufferWrite | O(n) | Up to 2 memcpy calls |
xRingBufferRead | O(n) | Up to 2 memcpy calls |
xRingBufferPeek | O(n) | Like Read but doesn't advance tail |
xRingBufferDiscard | O(1) | Just advances tail |
xRingBufferLen | O(1) | head - tail |
xRingBufferReadFd | O(1) | Single readv() syscall |
xRingBufferWriteFd | O(1) | Single writev() syscall |
io.h — Reference-Counted Block-Chain I/O Buffer
Introduction
io.h provides xIOBuffer, a non-contiguous byte buffer composed of a chain of reference-counted memory blocks. It supports zero-copy split, append, and scatter-gather I/O (readv/writev). Inspired by brpc's IOBuf, it is designed for high-throughput network I/O where avoiding memory copies is critical.
Design Philosophy
-
Block-Chain Architecture — Data is stored across multiple fixed-size blocks (default 8KB each), linked through a reference array. This avoids large contiguous allocations and enables zero-copy operations.
-
Reference Counting — Each
xIOBlockis reference-counted. MultiplexIOBufferinstances can share the same block (e.g., after aCutoperation). Blocks are freed (returned to pool) when the last reference is released. -
Zero-Copy Operations —
xIOBufferAppendIOBuffer()transfers block references without copying data.xIOBufferCut()splits a buffer by adjusting offsets and sharing blocks at the boundary. -
Lock-Free Block Pool — Released blocks are returned to a global Treiber stack (lock-free) for reuse, avoiding
malloc/freeoverhead in steady state. -
Inline Ref Array — Small buffers (≤ 8 refs) use an inline array, avoiding heap allocation for the ref array itself. Larger buffers transition to a heap-allocated array.
Architecture
graph TD
subgraph "xIOBuffer API"
APPEND["Append / AppendStr"]
APPEND_IO["AppendIOBuffer<br/>(zero-copy)"]
READ["Read / CopyTo"]
CUT["Cut<br/>(zero-copy split)"]
CONSUME["Consume"]
IO_READ["ReadFd"]
IO_WRITE["WriteFd<br/>(writev)"]
end
subgraph "Block Management"
ACQUIRE["xIOBlockAcquire"]
RETAIN["xIOBlockRetain"]
RELEASE["xIOBlockRelease"]
end
subgraph "Block Pool (Treiber Stack)"
POOL["g_pool_head"]
WARMUP["xIOBlockPoolWarmup"]
DRAIN["xIOBlockPoolDrain"]
end
APPEND --> ACQUIRE
IO_READ --> ACQUIRE
CUT --> RETAIN
CONSUME --> RELEASE
READ --> RELEASE
ACQUIRE --> POOL
RELEASE --> POOL
WARMUP --> POOL
DRAIN --> POOL
style POOL fill:#f5a623,color:#fff
API Reference
Configuration
| Macro | Default | Description |
|---|---|---|
XIOBUFFER_BLOCK_SIZE | 8192 | Block data size in bytes |
XIOBUFFER_INLINE_REFS | 8 | Inline ref array capacity |
Block API
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xIOBlockAcquire | xIOBlock *xIOBlockAcquire(void) | Get a block from pool (or malloc). refs=1. | Thread-safe (lock-free pool) |
xIOBlockRetain | void xIOBlockRetain(xIOBlock *blk) | Increment refcount. | Thread-safe (atomic) |
xIOBlockRelease | void xIOBlockRelease(xIOBlock *blk) | Decrement refcount; return to pool at 0. | Thread-safe (atomic + lock-free pool) |
xIOBlockPoolWarmup | xErrno xIOBlockPoolWarmup(size_t n) | Pre-allocate n blocks into pool. | Thread-safe |
xIOBlockPoolDrain | void xIOBlockPoolDrain(void) | Free all pooled blocks. Call at shutdown. | Not thread-safe (no concurrent use) |
IOBuffer Lifecycle
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xIOBufferInit | void xIOBufferInit(xIOBuffer *io) | Initialize an empty IOBuffer. | Not thread-safe |
xIOBufferDeinit | void xIOBufferDeinit(xIOBuffer *io) | Release all refs and free ref array. | Not thread-safe |
xIOBufferReset | void xIOBufferReset(xIOBuffer *io) | Release all refs, keep ref array. | Not thread-safe |
IOBuffer Query
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xIOBufferLen | size_t xIOBufferLen(const xIOBuffer *io) | Total readable bytes. | Not thread-safe |
xIOBufferEmpty | bool xIOBufferEmpty(const xIOBuffer *io) | True if no data. | Not thread-safe |
xIOBufferRefCount | size_t xIOBufferRefCount(const xIOBuffer *io) | Number of block refs. | Not thread-safe |
IOBuffer Write
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xIOBufferAppend | xErrno xIOBufferAppend(xIOBuffer *io, const void *data, size_t len) | Append bytes (allocates blocks as needed). | Not thread-safe |
xIOBufferAppendStr | xErrno xIOBufferAppendStr(xIOBuffer *io, const char *str) | Append C string. | Not thread-safe |
xIOBufferAppendIOBuffer | xErrno xIOBufferAppendIOBuffer(xIOBuffer *io, xIOBuffer *other) | Zero-copy: move all refs from other. | Not thread-safe |
IOBuffer Read
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xIOBufferRead | size_t xIOBufferRead(xIOBuffer *io, void *out, size_t len) | Copy and consume bytes. | Not thread-safe |
xIOBufferCut | size_t xIOBufferCut(xIOBuffer *io, xIOBuffer *dst, size_t n) | Zero-copy split: move first n bytes to dst. | Not thread-safe |
xIOBufferConsume | size_t xIOBufferConsume(xIOBuffer *io, size_t n) | Discard first n bytes. | Not thread-safe |
xIOBufferCopyTo | size_t xIOBufferCopyTo(const xIOBuffer *io, void *out) | Linearize: copy all data to contiguous buffer. | Not thread-safe |
IOBuffer I/O
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xIOBufferReadIov | int xIOBufferReadIov(const xIOBuffer *io, struct iovec *iov, int max_iov) | Fill iovecs for writev(). | Not thread-safe |
xIOBufferReadFd | ssize_t xIOBufferReadFd(xIOBuffer *io, int fd) | Read from fd into IOBuffer. | Not thread-safe |
xIOBufferWriteFd | ssize_t xIOBufferWriteFd(xIOBuffer *io, int fd) | Write to fd using writev(). | Not thread-safe |
Usage Examples
Basic Usage
#include <stdio.h>
#include <x/buf/io.h>
int main(void) {
xIOBuffer io;
xIOBufferInit(&io);
// Append data (may span multiple blocks)
xIOBufferAppend(&io, "Hello, ", 7);
xIOBufferAppend(&io, "IOBuffer!", 9);
printf("Length: %zu, Refs: %zu\n",
xIOBufferLen(&io), xIOBufferRefCount(&io));
// Linearize for processing
char buf[64];
xIOBufferCopyTo(&io, buf);
printf("Content: %.*s\n", (int)xIOBufferLen(&io), buf);
xIOBufferDeinit(&io);
return 0;
}
Zero-Copy Split (Protocol Parsing)
#include <x/buf/io.h>
void parse_protocol(xIOBuffer *io) {
// Cut the 4-byte header from the front
xIOBuffer header;
xIOBufferInit(&header);
size_t cut = xIOBufferCut(io, &header, 4);
if (cut == 4) {
char hdr[4];
xIOBufferRead(&header, hdr, 4);
// Parse header...
// io now contains only the body (zero-copy!)
}
xIOBufferDeinit(&header);
}
High-Throughput Network I/O
#include <x/buf/io.h>
void handle_data(int sockfd) {
// Pre-warm the block pool at startup
xIOBlockPoolWarmup(64);
xIOBuffer io;
xIOBufferInit(&io);
// Read from socket (allocates blocks from pool)
ssize_t n = xIOBufferReadFd(&io, sockfd);
if (n > 0) {
// Write back using scatter-gather I/O
xIOBufferWriteFd(&io, sockfd);
}
xIOBufferDeinit(&io);
// At shutdown
xIOBlockPoolDrain();
}
Use Cases
-
HTTP Response Body — The
xhttpmodule usesxIOBufferto accumulate response chunks from libcurl without copying between buffers. -
Protocol Framing — Use
xIOBufferCut()to split headers from body in a zero-copy fashion, then process each part independently. -
Data Pipeline — Chain multiple processing stages that each append to or cut from
xIOBufferinstances, sharing blocks to minimize copies.
Best Practices
- Call
xIOBlockPoolWarmup()at startup to pre-allocate blocks and avoid allocation spikes during initial traffic. - Call
xIOBlockPoolDrain()at shutdown for clean valgrind reports. - Use
xIOBufferAppendIOBuffer()instead of copying when combining buffers. It transfers ownership without data copies. - Use
xIOBufferCut()for protocol parsing. It's more efficient thanxIOBufferRead()when you need to pass the cut data to another component. - Monitor
xIOBufferRefCount()to understand memory fragmentation. Many small refs may indicate suboptimal block utilization.
Comparison with Other Libraries
| Feature | xbuf io.h | brpc IOBuf | Netty ByteBuf | Go bytes.Buffer |
|---|---|---|---|---|
| Architecture | Block-chain (ref array) | Block-chain (linked list) | Composite buffer | Contiguous slice |
| Block Size | 8KB (configurable) | 8KB | Configurable | N/A |
| Reference Counting | Atomic (per block) | Atomic (per block) | Atomic (per buffer) | GC |
| Zero-Copy Split | xIOBufferCut | cutn | slice | No |
| Zero-Copy Append | xIOBufferAppendIOBuffer | append(IOBuf) | addComponent | No |
| Block Pool | Treiber stack (lock-free) | Thread-local + global | Arena allocator | N/A |
| Scatter-Gather I/O | writev via ReadIov | writev via pappend | nioBuffers | No |
| Inline Optimization | 8 inline refs | No | No | N/A |
| Language | C99 | C++ | Java | Go |
Key Differentiator: xbuf's xIOBuffer combines brpc-style block-chain architecture with a lock-free Treiber stack block pool and inline ref optimization. The zero-copy Cut and AppendIOBuffer operations make it ideal for protocol parsing and data pipeline scenarios in C.
Benchmark
Environment: Apple M3 Pro, 36 GB RAM, macOS 26.4, Release build (
-O2). Source:xbuf/io_bench.cpp
| Benchmark | Size | Time (ns) | CPU (ns) | Throughput |
|---|---|---|---|---|
BM_IOBuffer_Append | 64 | 3,720 | 3,720 | 16.0 GiB/s |
BM_IOBuffer_Append | 256 | 7,569 | 7,568 | 31.5 GiB/s |
BM_IOBuffer_Append | 1,024 | 22,341 | 22,340 | 42.7 GiB/s |
BM_IOBuffer_Append | 4,096 | 79,796 | 79,794 | 47.8 GiB/s |
BM_IOBuffer_Append | 8,192 | 187,167 | 187,165 | 40.8 GiB/s |
BM_IOBuffer_AppendConsume | 64 | 5,230 | 5,230 | 11.4 GiB/s |
BM_IOBuffer_AppendConsume | 256 | 8,232 | 8,232 | 29.0 GiB/s |
BM_IOBuffer_AppendConsume | 1,024 | 23,040 | 23,040 | 41.4 GiB/s |
BM_IOBuffer_Cut | 8,192 | 167 | 167 | 45.6 GiB/s |
BM_IOBuffer_Cut | 65,536 | 1,651 | 1,651 | 37.0 GiB/s |
BM_IOBuffer_Cut | 262,144 | 8,122 | 8,122 | 30.1 GiB/s |
BM_IOBuffer_AppendIOBuffer | 1,024 | 3,196 | 3,196 | 29.8 GiB/s |
BM_IOBuffer_AppendIOBuffer | 4,096 | 9,307 | 9,307 | 41.0 GiB/s |
BM_IOBuffer_AppendIOBuffer | 8,192 | 17,604 | 17,602 | 43.3 GiB/s |
BM_IOBuffer_BlockPool | — | 8.91 | 8.89 | — |
Key Observations:
- Append peaks at ~48 GiB/s for 4KB chunks. The slight drop at 8KB reflects block boundary crossing overhead.
- Cut (zero-copy split) is extremely fast — 167ns for 8KB — because it only manipulates reference metadata, not data. This validates the block-chain architecture for protocol parsing.
- AppendIOBuffer (zero-copy concatenation) achieves ~43 GiB/s, confirming that block ownership transfer avoids data copies.
- BlockPool acquire/release cycle takes ~9ns, showing the lock-free Treiber stack's efficiency for block recycling.
Implementation Details
Block Structure
XDEF_STRUCT(xIOBlock) {
size_t refs; // Reference count (atomic)
size_t size; // Usable data size
char data[XIOBUFFER_BLOCK_SIZE]; // 8KB inline data
};
Reference Structure
XDEF_STRUCT(xIOBufferRef) {
xIOBlock *block; // Pointer to the underlying block
size_t offset; // Start offset within block->data
size_t length; // Number of valid bytes from offset
};
IOBuffer Structure
XDEF_STRUCT(xIOBuffer) {
xIOBufferRef inlined[XIOBUFFER_INLINE_REFS]; // Inline ref storage (8)
xIOBufferRef *refs; // Pointer to ref array (inlined or heap)
size_t nrefs; // Number of active refs
size_t cap; // Capacity of refs array
size_t nbytes; // Total logical byte count (cached)
};
Block-Chain Architecture
graph TD
subgraph "xIOBuffer"
REF1["Ref 0<br/>block=A, off=0, len=8192"]
REF2["Ref 1<br/>block=B, off=0, len=8192"]
REF3["Ref 2<br/>block=C, off=0, len=3000"]
end
subgraph "Shared Blocks"
A["xIOBlock A<br/>refs=1, 8KB"]
B["xIOBlock B<br/>refs=2, 8KB"]
C["xIOBlock C<br/>refs=1, 8KB"]
end
REF1 --> A
REF2 --> B
REF3 --> C
subgraph "Another xIOBuffer (after Cut)"
REF4["Ref 0<br/>block=B, off=4096, len=4096"]
end
REF4 --> B
style A fill:#4a90d9,color:#fff
style B fill:#f5a623,color:#fff
style C fill:#50b86c,color:#fff
Treiber Stack Block Pool
The global block pool uses a lock-free Treiber stack:
// Pool node overlays xIOBlock memory
XDEF_STRUCT(PoolNode_) {
PoolNode_ *next;
};
static PoolNode_ *volatile g_pool_head = NULL;
Push (return to pool):
do {
head = atomic_load(g_pool_head)
node->next = head
} while (!CAS(g_pool_head, head, node))
Pop (acquire from pool):
do {
head = atomic_load(g_pool_head)
if (!head) return malloc(new block)
next = head->next
} while (!CAS(g_pool_head, head, next))
return head
Zero-Copy Cut
xIOBufferCut(io, dst, n) moves the first n bytes from io to dst:
- Fully consumed refs — Ownership transfers directly (no refcount change).
- Boundary ref — The block is shared:
xIOBlockRetain()increments the refcount, and both buffers hold a ref with different offset/length.
flowchart TD
CUT["xIOBufferCut(io, dst, n)"]
LOOP{"More bytes to cut?"}
FULL{"ref.length <= remaining?"}
TRANSFER["Transfer entire ref to dst<br/>(no refcount change)"]
SPLIT["Share block: Retain + split ref<br/>dst gets [offset, chunk]<br/>io keeps [offset+chunk, rest]"]
SHIFT["Shift consumed refs out of io"]
DONE["Update nbytes for both"]
CUT --> LOOP
LOOP -->|Yes| FULL
FULL -->|Yes| TRANSFER --> LOOP
FULL -->|No| SPLIT --> SHIFT --> DONE
LOOP -->|No| SHIFT
style TRANSFER fill:#50b86c,color:#fff
style SPLIT fill:#f5a623,color:#fff
Append Strategy
xIOBufferAppend(io, data, len):
- First tries to fill the tail block's remaining space (avoids allocating a new block for small appends).
- Allocates new blocks for remaining data, each up to
XIOBUFFER_BLOCK_SIZEbytes.
xcrypto — Cryptographic Primitives
Introduction
xcrypto is libx's cryptographic module, providing common hash functions, checksums, and HMAC primitives for use by higher-level modules. It currently offers:
- Hash functions: SHA-1, SHA-256, MD5
- Checksum: CRC-32
- HMAC: Generic HMAC (RFC 2104) with streaming API, plus convenience wrappers for HMAC-SHA1, HMAC-SHA256, and HMAC-MD5
SHA-1 and SHA-256 support three backends selected at build time via X_TLS_BACKEND: OpenSSL, mbedTLS, and a pure-C builtin fallback. MD5 and CRC-32 are always pure-C with no external dependencies.
Design Philosophy
-
Backend Abstraction — Hash headers (
sha1.h,sha256.h) expose a unified API regardless of the underlying crypto library. The backend is selected at build time viaX_TLS_BACKEND, keeping runtime overhead at zero and the public interface stable. -
Zero Heap Allocation — All context structures (
xSha1Ctx,xSha256Ctx,xMd5Ctx,xHmacCtx) use fixed-size opaque buffers large enough to hold any backend's internal state. No dynamic allocation is needed. -
Dual API Surface — Every hash algorithm provides both a one-shot function (e.g.
xSha256()) for simple use cases and a streaming API (Init/Update/Final) for incremental hashing of large or chunked data. The generic HMAC also supports both modes. -
Compile-Time Static Assertions — Each backend implementation uses
_Static_assertto verify at compile time that the opaque buffer is large enough for its internal state, catching size mismatches before they become runtime bugs. -
Consistent Error Handling — All functions return
xErrnocodes and validate arguments defensively, following the same error convention used throughout libx. -
Generic HMAC via Vtable — The HMAC implementation is hash-agnostic, driven by an
xHashVtablethat describes any hash algorithm's init/update/final/sizes. Adding HMAC for a new hash requires only a one-line vtable definition.
Architecture
graph TD
subgraph "Public API"
SHA1_H["sha1.h<br/>xSha1() / Init / Update / Final"]
SHA256_H["sha256.h<br/>xSha256() / Init / Update / Final"]
MD5_H["md5.h<br/>xMd5() / Init / Update / Final"]
CRC32_H["crc32.h<br/>xCrc32()"]
HMAC_H["hmac.h<br/>xHmac() / Init / Update / Final"]
HMAC_SHA1_H["hmac_sha1.h — xHmacSha1()"]
HMAC_SHA256_H["hmac_sha256.h — xHmacSha256()"]
HMAC_MD5_H["hmac_md5.h — xHmacMd5()"]
end
subgraph "Backend Implementations"
SHA1_SSL["sha1_openssl.c"]
SHA1_MBED["sha1_mbedtls.c"]
SHA1_BUILT["sha1_builtin.c"]
SHA256_SSL["sha256_openssl.c"]
SHA256_MBED["sha256_mbedtls.c"]
SHA256_BUILT["sha256_builtin.c"]
MD5_C["md5.c (pure C)"]
CRC32_C["crc32.c (pure C)"]
end
subgraph "Generic HMAC Engine"
HMAC_C["hmac.c (RFC 2104)"]
VTABLE["xHashVtable"]
end
SHA1_H --> SHA1_SSL & SHA1_MBED & SHA1_BUILT
SHA256_H --> SHA256_SSL & SHA256_MBED & SHA256_BUILT
MD5_H --> MD5_C
CRC32_H --> CRC32_C
HMAC_SHA1_H --> HMAC_C
HMAC_SHA256_H --> HMAC_C
HMAC_MD5_H --> HMAC_C
HMAC_H --> HMAC_C
HMAC_C --> VTABLE
VTABLE -.->|"sha1"| SHA1_H
VTABLE -.->|"sha256"| SHA256_H
VTABLE -.->|"md5"| MD5_H
style SHA1_H fill:#4a90d9,color:#fff
style SHA256_H fill:#4a90d9,color:#fff
style MD5_H fill:#4a90d9,color:#fff
style CRC32_H fill:#4a90d9,color:#fff
style HMAC_H fill:#4a90d9,color:#fff
style HMAC_SHA1_H fill:#9b59b6,color:#fff
style HMAC_SHA256_H fill:#9b59b6,color:#fff
style HMAC_MD5_H fill:#9b59b6,color:#fff
style HMAC_C fill:#e67e22,color:#fff
style VTABLE fill:#e67e22,color:#fff
Backend Selection
SHA-1 and SHA-256 backends are chosen via the X_TLS_BACKEND CMake variable. MD5 and CRC-32 are always pure-C.
X_TLS_BACKEND | SHA-1 / SHA-256 Backend | External Dependency |
|---|---|---|
openssl | OpenSSL EVP API | libssl, libcrypto |
mbedtls | mbedTLS | libmbedtls |
auto | Auto-detect: OpenSSL → mbedTLS → builtin | Best available |
| (anything else) | Pure-C builtin | None |
When set to auto, CMake probes for OpenSSL first, then mbedTLS, and falls back to the builtin implementation if neither is found.
Sub-Module Overview
| Header | Description |
|---|---|
sha1.h | SHA-1 hash — one-shot and streaming API with pluggable backend |
sha256.h | SHA-256 hash — one-shot and streaming API with pluggable backend |
md5.h | MD5 hash — one-shot and streaming API (pure C, RFC 1321) |
crc32.h | CRC-32 checksum — one-shot API (pure C, ISO 3309) |
hmac.h | Generic HMAC — one-shot and streaming API (RFC 2104), works with any xHashVtable |
hmac_sha1.h | HMAC-SHA1 convenience wrapper |
hmac_sha256.h | HMAC-SHA256 convenience wrapper |
hmac_md5.h | HMAC-MD5 convenience wrapper |
uuid.h | UUID generation (RFC 4122 / RFC 9562) — v4 random, v7 time-ordered, v5 namespace+SHA-1 (docs) |
Hash Constants
| Constant | Value | Description |
|---|---|---|
XCRYPTO_SHA1_DIGEST_SIZE | 20 | SHA-1 digest length in bytes |
XCRYPTO_SHA1_BLOCK_SIZE | 64 | SHA-1 internal block size in bytes |
XCRYPTO_SHA256_DIGEST_SIZE | 32 | SHA-256 digest length in bytes |
XCRYPTO_SHA256_BLOCK_SIZE | 64 | SHA-256 internal block size in bytes |
XCRYPTO_MD5_DIGEST_SIZE | 16 | MD5 digest length in bytes |
XCRYPTO_MD5_BLOCK_SIZE | 64 | MD5 internal block size in bytes |
Hash Functions
| Function | Description |
|---|---|
xSha1(data, len, digest) | One-shot SHA-1 |
xSha1Init(ctx) / xSha1Update(ctx, data, len) / xSha1Final(ctx, digest) | Streaming SHA-1 |
xSha256(data, len, digest) | One-shot SHA-256 |
xSha256Init(ctx) / xSha256Update(ctx, data, len) / xSha256Final(ctx, digest) | Streaming SHA-256 |
xMd5(data, len, digest) | One-shot MD5 |
xMd5Init(ctx) / xMd5Update(ctx, data, len) / xMd5Final(ctx, digest) | Streaming MD5 |
xCrc32(data, len) | One-shot CRC-32 (returns uint32_t) |
HMAC Functions
| Function | Description |
|---|---|
xHmac(hash, key, key_len, data, data_len, digest) | Generic one-shot HMAC with any xHashVtable |
xHmacInit(ctx, hash, key, key_len) / xHmacUpdate(ctx, data, len) / xHmacFinal(ctx, digest) | Generic streaming HMAC |
xHmacSha1(key, key_len, data, data_len, digest) | One-shot HMAC-SHA1 convenience wrapper |
xHmacSha256(key, key_len, data, data_len, digest) | One-shot HMAC-SHA256 convenience wrapper |
xHmacMd5(key, key_len, data, data_len, digest) | One-shot HMAC-MD5 convenience wrapper |
All functions return xErrno_Ok on success (except xCrc32 which returns the checksum directly). After calling a Final function, the context must be re-initialized before reuse.
Quick Start
One-Shot SHA-256
#include <stdio.h>
#include <string.h>
#include <x/crypto/sha256.h>
int main(void) {
const char *msg = "Hello, World!";
uint8_t digest[XCRYPTO_SHA256_DIGEST_SIZE];
xErrno err = xSha256((const uint8_t *)msg, strlen(msg), digest);
if (err != xErrno_Ok) return 1;
printf("SHA-256: ");
for (int i = 0; i < XCRYPTO_SHA256_DIGEST_SIZE; i++) {
printf("%02x", digest[i]);
}
printf("\n");
return 0;
}
HMAC-SHA256
#include <stdio.h>
#include <string.h>
#include <x/crypto/hmac_sha256.h>
int main(void) {
const char *key = "secret";
const char *msg = "Hello, World!";
uint8_t digest[32];
xErrno err = xHmacSha256(
(const uint8_t *)key, strlen(key),
(const uint8_t *)msg, strlen(msg),
digest);
if (err != xErrno_Ok) return 1;
printf("HMAC-SHA256: ");
for (int i = 0; i < 32; i++) {
printf("%02x", digest[i]);
}
printf("\n");
return 0;
}
Streaming HMAC (Generic)
#include <x/crypto/hmac.h>
#include <x/crypto/hmac_sha1.h> /* for xHashVtableSha1 */
int main(void) {
xHmacCtx ctx;
uint8_t digest[20];
xHmacInit(&ctx, &xHashVtableSha1,
(const uint8_t *)"key", 3);
xHmacUpdate(&ctx, (const uint8_t *)"Hello, ", 7);
xHmacUpdate(&ctx, (const uint8_t *)"World!", 6);
xHmacFinal(&ctx, digest);
return 0;
}
Compile with:
gcc -o example example.c -I/path/to/libx -lxcrypto -lxbase
Relationship with Other Modules
graph LR
XCRYPTO["xcrypto"]
XBASE["xbase"]
XHTTP["xhttp"]
XP2P["xp2p"]
XFER["xfer"]
XCRYPTO -->|"error codes + base types"| XBASE
XHTTP -.->|"WebSocket handshake SHA-1"| XCRYPTO
XP2P -.->|"STUN HMAC-SHA1 + CRC-32"| XCRYPTO
XFER -.->|"SHA-1 integrity check"| XCRYPTO
style XCRYPTO fill:#4a90d9,color:#fff
style XBASE fill:#50b86c,color:#fff
style XHTTP fill:#f5a623,color:#fff
style XP2P fill:#e74c3c,color:#fff
style XFER fill:#9b59b6,color:#fff
- xbase — xcrypto depends on xbase for
xErrnoerror codes,XDEF_STRUCT, andXCAPImacros. - xhttp — The WebSocket handshake (RFC 6455) requires SHA-1 to compute the
Sec-WebSocket-Acceptheader. - xp2p — STUN message integrity (RFC 5389) uses HMAC-SHA1 and CRC-32 fingerprint. xp2p uses xcrypto directly.
- xfer — File transfer integrity verification uses SHA-1 checksums from xcrypto.
uuid.h — UUID Generation
Introduction
uuid.h provides UUID generation, formatting, parsing, and comparison per RFC 4122 / RFC 9562. Three versions are supported:
- v4 — Random UUID. Uses
xRandomBytesfor cryptographically secure randomness. - v7 — Time-ordered UUID. 48-bit Unix timestamp (ms) in the high bits + 74 random bits. Sortable, database-friendly.
- v5 — Namespace + name SHA-1 UUID. Deterministic — same namespace + name always produces the same UUID. Uses
xSha1fromxcrypto.
UUIDs are 16-byte value types (xUuid). They are stack-allocatable with no lifetime management. All generation functions return by value.
Design Philosophy
-
Value Type —
xUuidis a struct of 16 bytes. No heap allocation, no opaque handle, no destroy function. Safe to copy, assign, and pass by value. -
Cryptographic Randomness — v4 and v7 use
xRandomBytes(kernel CSPRNG or/dev/urandom), notrand()or a PRNG. Suitable for security-sensitive identifiers. -
Time-Ordered by Default — v7 is recommended for database primary keys. The 48-bit millisecond timestamp sorts chronologically, reducing index fragmentation.
-
No UUID v1 — MAC address + timestamp UUIDs (v1) leak hardware identity and clock sequence. Not implemented.
-
Consistent String Format —
xUuidToStringalways produces lowercase with hyphens (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).xUuidFromStringis case-insensitive and accepts any hyphenation.
Architecture
flowchart TD
V4["xUuidV4()"]
V7["xUuidV7()"]
V5["xUuidV5(ns, name)"]
RAND["xRandomBytes()"]
TIME["xMonoMs()"]
SHA1["xSha1()"]
V4 --> RAND
V7 --> RAND
V7 --> TIME
V5 --> SHA1
V4 --> FMT["xUuidToString()"]
V5 --> FMT
V7 --> FMT
PARSE["xUuidFromString()"]
CMP["xUuidCompare()"]
NIL["xUuidIsNil()"]
style V4 fill:#4a90d9,color:#fff
style V7 fill:#4a90d9,color:#fff
style V5 fill:#4a90d9,color:#fff
style RAND fill:#50b86c,color:#fff
style SHA1 fill:#f5a623,color:#fff
API Reference
Generation
| Function | Signature | Description |
|---|---|---|
xUuidV4 | xUuid xUuidV4(void) | Generate a random UUID (v4). Version bits: 4 in octet 6, 10xx in octet 8. |
xUuidV7 | xUuid xUuidV7(void) | Generate a time-ordered UUID (v7). 48-bit Unix ms timestamp, 74 random bits. |
xUuidV5 | xUuid xUuidV5(xUuid ns, const char *name) | Generate a namespace + name SHA-1 UUID (v5). Deterministic. |
Formatting
| Function | Signature | Description |
|---|---|---|
xUuidToString | void xUuidToString(xUuid uuid, char buf[37]) | Format as lowercase hyphenated string. buf must be at least 37 bytes. |
xUuidFromString | xErrno xUuidFromString(const char *str, xUuid *out) | Parse a UUID string. Case-insensitive, any hyphenation accepted. Returns xErrno_Ok or xErrno_Invalid. |
Comparison
| Function | Signature | Description |
|---|---|---|
xUuidCompare | int xUuidCompare(xUuid a, xUuid b) | Lexicographic byte comparison. Returns <0, 0, or >0. |
xUuidIsNil | bool xUuidIsNil(xUuid uuid) | Returns true if all 16 bytes are zero. |
Namespace UUIDs
| Function | Returns |
|---|---|
xUuidNamespaceDns() | const xUuid * — 6ba7b810-9dad-11d1-80b4-00c04fd430c8 |
xUuidNamespaceUrl() | const xUuid * — 6ba7b811-9dad-11d1-80b4-00c04fd430c8 |
Types
| Type | Description |
|---|---|
xUuid | XDEF_STRUCT(xUuid) { uint8_t bytes[16]; } — 16-byte value type. |
Usage Examples
Basic v4
#include <stdio.h>
#include <x/crypto/uuid.h>
int main(void) {
xUuid id = xUuidV4();
char buf[37];
xUuidToString(id, buf);
printf("v4: %s\n", buf);
// Output: v4: 550e8400-e29b-41d4-a716-446655440000
return 0;
}
v7 for database primary keys
xUuid pk = xUuidV7();
char buf[37];
xUuidToString(pk, buf);
// e.g. "018f3a2b-7000-7a1b-9c2d-3e4f5a6b7c8d"
// The first 12 hex digits encode the creation timestamp (ms since Unix epoch).
// These sort chronologically, reducing B-tree fragmentation.
v5 for deterministic IDs
xUuid ns = *xUuidNamespaceDns();
xUuid id = xUuidV5(ns, "example.com");
// Same input → same output, across all platforms and runs:
// cfba97cc-8a5a-5e8f-9e4f-9b1c2d3e4f5a
Parse and compare
xUuid a = xUuidV4();
xUuid b = xUuidV4();
if (xUuidCompare(a, b) == 0) {
printf("equal (astronomically unlikely for v4)\n");
}
// Parse from string
xUuid parsed;
xUuidFromString("550e8400-e29b-41d4-a716-446655440000", &parsed);
// Check for nil
xUuid nil = {0};
assert(xUuidIsNil(nil));
assert(!xUuidIsNil(a));
Best Practices
- Prefer v7 for database keys — Time-ordered UUIDs reduce index fragmentation compared to v4.
- Use v5 for content-addressed IDs — e.g.
xUuidV5(*xUuidNamespaceUrl(), url)produces a stable identifier. - Don't rely on v4 uniqueness for security — v4 is 122 random bits; collision probability is negligible, but don't use it as a cryptographic nonce.
- Always check
xUuidFromStringreturn value — Malformed strings producexErrno_Invalid. - Allocate 37 bytes for
xUuidToStringoutput — 32 hex digits + 4 hyphens + NUL.
Relationship with Other Modules
- xbase — Uses
xRandomBytesfor v4 and v7 random bits, andxMonoMs()for v7 timestamps. - xcrypto — Uses
xSha1()fromsha1.hfor v5 name hashing. UUID lives in xcrypto (not xbase) because v5's SHA-1 dependency would create a circular dependency.
xnet — Networking Primitives
Introduction
xnet is libx's networking utility module, providing three foundational components for network programming: a lightweight URL parser, an asynchronous DNS resolver, and shared TLS configuration types. These building blocks are used internally by higher-level modules like xhttp, and are also available for direct use in application code.
Design Philosophy
-
Zero-Copy URL Parsing —
xUrlParse()makes a single internal copy of the input string. All component fields (scheme, host, port, etc.) are pointer+length pairs referencing this copy, avoiding per-field allocations. -
Async DNS via Thread-Pool Offload — DNS resolution uses
getaddrinfo()offloaded to the event loop's thread pool. The callback is always invoked on the event loop thread, keeping the async programming model consistent with the rest of libx. -
Shared TLS Types —
xTlsConfis a plain data structure shared across modules. It decouples TLS configuration from any specific TLS backend (OpenSSL, mbedTLS). -
Async TCP with Transport Abstraction —
xTcpConnectchains DNS → connect → optional TLS handshake into a single async operation.xTcpConnwraps anxSocket+xTransportvtable, providingRecv/Send/SendIovhelpers that work transparently over plain TCP or TLS.
Architecture
graph TD
subgraph "xnet Module"
URL["xUrl<br/>URL Parser<br/>url.h"]
DNS["xDnsResolve<br/>Async DNS<br/>dns.h"]
TLS["xTlsConf<br/>TLS Config Types<br/>tls.h"]
TCP["xTcpConn / xTcpConnect / xTcpListener<br/>Async TCP<br/>tcp.h"]
end
subgraph "xbase Infrastructure"
EV["xEventLoop<br/>event.h"]
POOL["Thread Pool<br/>xEventLoopSubmit()"]
ATOMIC["Atomic Ops<br/>atomic.h"]
end
subgraph "Consumers"
HTTP_C["xhttp Client"]
HTTP_S["xhttp Server"]
WS["WebSocket"]
end
DNS --> EV
DNS --> POOL
DNS --> ATOMIC
TCP --> EV
TCP --> DNS
TCP --> TLS
HTTP_C --> URL
HTTP_C --> TCP
HTTP_S --> TCP
WS --> URL
WS --> TCP
style URL fill:#4a90d9,color:#fff
style DNS fill:#50b86c,color:#fff
style TLS fill:#f5a623,color:#fff
style TCP fill:#e74c3c,color:#fff
Sub-Module Overview
| Header | Component | Description | Doc |
|---|---|---|---|
url.h | xUrl | Lightweight URL parser | url.md |
dns.h | xDnsResolve | Async DNS resolution | dns.md |
tls.h | xTlsConf | Shared TLS config types | tls.md |
tcp.h | xTcpConn / xTcpConnect / xTcpListener | Async TCP connection, connector & listener | tcp.md |
Quick Start
#include <stdio.h>
#include <x/base/event.h>
#include <x/net/url.h>
#include <x/net/dns.h>
#include <x/net/tls.h>
// 1. Parse a URL
static void url_example(void) {
xUrl url;
xErrno err = xUrlParse(
"wss://example.com:8443/ws?token=abc", &url);
if (err == xErrno_Ok) {
printf("scheme: %.*s\n",
(int)url.scheme_len, url.scheme);
printf("host: %.*s\n",
(int)url.host_len, url.host);
printf("port: %u\n", xUrlPort(&url));
printf("path: %.*s\n",
(int)url.path_len, url.path);
xUrlFree(&url);
}
}
// 2. Async DNS resolution
static void on_resolved(xDnsResult *result, void *arg) {
(void)arg;
if (result->error == xErrno_Ok) {
int count = 0;
for (xDnsAddr *a = result->addrs; a; a = a->next)
count++;
printf("Resolved %d address(es)\n", count);
}
xDnsResultFree(result);
// stop the loop after resolution
}
static void dns_example(xEventLoop loop) {
xDnsResolve(loop, "example.com", "443",
NULL, on_resolved, NULL);
}
// 3. TLS configuration
static void tls_example(void) {
xTlsConf client_tls = {0};
client_tls.ca = "ca.pem";
xTlsConf server_tls = {
.cert = "server.pem",
.key = "server-key.pem",
};
(void)client_tls;
(void)server_tls;
}
Relationship with Other Modules
- xbase — The DNS resolver depends on
xEventLoopfor thread-pool offload and usesatomic.hfor the cancellation flag. - xhttp — The HTTP client uses
xUrlfor URL parsing,xDnsResolvefor hostname resolution, andxTlsConffor TLS configuration. The WebSocket client supports bothxTlsConfand a sharedxTlsCtxforwss://connections. See the TLS Deployment Guide for end-to-end examples. - WebSocket — The WebSocket client uses
xUrlto parsews://andwss://URLs, and optionally accepts a sharedxTlsCtxto avoid per-connection TLS context creation.
url.h — Lightweight URL Parser
Introduction
url.h provides xUrl, a lightweight URL parser that decomposes a URL string into its RFC 3986 components: scheme, userinfo, host, port, path, query, and fragment. The parser makes a single internal copy of the input; all component fields are pointer+length pairs referencing this copy, so the caller may discard the original string immediately after parsing.
Design Philosophy
-
Single Copy, Zero Per-Field Allocation —
xUrlParse()callsstrdup()once. All output fields point into this copy, avoiding per-component heap allocations. -
Pointer+Length Pairs — Fields use
const char *+size_tpairs rather than NUL-terminated strings. This avoids mutating the internal copy and supports efficient substring access. -
Scheme-Aware Default Ports —
xUrlPort()returns well-known default ports (80 for http/ws, 443 for https/wss) when no explicit port is present, simplifying connection logic. -
IPv6 Literal Support — The parser correctly handles bracketed IPv6 addresses (
[::1]:8080), extracting the bare address without brackets.
Architecture
flowchart LR
INPUT["Raw URL string"]
PARSE["xUrlParse()"]
COPY["strdup() internal copy"]
FIELDS["Pointer+Length fields"]
PORT["xUrlPort()"]
FREE["xUrlFree()"]
INPUT --> PARSE
PARSE --> COPY
COPY --> FIELDS
FIELDS --> PORT
FIELDS --> FREE
style PARSE fill:#4a90d9,color:#fff
style FREE fill:#e74c3c,color:#fff
API Reference
Lifecycle
| Function | Signature | Description |
|---|---|---|
xUrlParse | xErrno xUrlParse(const char *raw, xUrl *url) | Parse a URL into components |
xUrlFree | void xUrlFree(xUrl *url) | Free internal copy, zero all fields |
Query
| Function | Signature | Description |
|---|---|---|
xUrlPort | uint16_t xUrlPort(const xUrl *url) | Numeric port (explicit or default by scheme) |
xUrl Fields
| Field | Type | Description |
|---|---|---|
scheme / scheme_len | const char * / size_t | e.g. "https" |
userinfo / userinfo_len | const char * / size_t | e.g. "user:pass" (optional) |
host / host_len | const char * / size_t | e.g. "example.com" or "::1" |
port / port_len | const char * / size_t | e.g. "8443" (optional) |
path / path_len | const char * / size_t | e.g. "/ws/chat" (optional) |
query / query_len | const char * / size_t | e.g. "key=val" (optional) |
fragment / fragment_len | const char * / size_t | e.g. "section1" (optional) |
Note: Optional fields have
ptr=NULL, len=0when absent. Theraw_field is internal — do not access it.
Usage Examples
Basic URL Parsing
#include <stdio.h>
#include <x/net/url.h>
int main(void) {
xUrl url;
xErrno err = xUrlParse("https://user:[email protected]:8443/ws/chat?token=abc#top", &url);
if (err != xErrno_Ok) {
fprintf(stderr, "parse failed\n");
return 1;
}
printf("scheme: %.*s\n", (int)url.scheme_len, url.scheme);
printf("userinfo: %.*s\n", (int)url.userinfo_len, url.userinfo);
printf("host: %.*s\n", (int)url.host_len, url.host);
printf("port: %.*s (numeric: %u)\n", (int)url.port_len, url.port, xUrlPort(&url));
printf("path: %.*s\n", (int)url.path_len, url.path);
printf("query: %.*s\n", (int)url.query_len, url.query);
printf("fragment: %.*s\n", (int)url.fragment_len, url.fragment);
xUrlFree(&url);
return 0;
}
Output:
scheme: https
userinfo: user:pass
host: example.com
port: 8443 (numeric: 8443)
path: /ws/chat
query: token=abc
fragment: top
IPv6 Address
xUrl url;
xUrlParse("http://[::1]:8080/test", &url);
printf("host: %.*s\n", (int)url.host_len, url.host);
// Output: host: ::1 (brackets stripped)
printf("port: %u\n", xUrlPort(&url));
// Output: port: 8080
xUrlFree(&url);
Default Port by Scheme
xUrl url;
xUrlParse("wss://echo.example.com/sock", &url);
// No explicit port in URL
printf("port field: %s\n", url.port ? "present" : "absent");
// Output: port field: absent
// xUrlPort() returns 443 for wss://
printf("effective port: %u\n", xUrlPort(&url));
// Output: effective port: 443
xUrlFree(&url);
Ownership Semantics
// xUrl owns its data — the original string can be freed
char *heap = strdup("ws://example.com:9090/ws");
xUrl url;
xUrlParse(heap, &url);
free(heap); // safe: xUrl has its own copy
// url fields are still valid here
printf("host: %.*s\n", (int)url.host_len, url.host);
xUrlFree(&url);
// After free, all fields are zeroed (NULL)
Error Handling
| Input | Result |
|---|---|
NULL raw or url pointer | xErrno_InvalidArg |
Missing :// separator | xErrno_InvalidArg |
Empty host (e.g. http:///path) | xErrno_InvalidArg |
| Unclosed IPv6 bracket | xErrno_InvalidArg |
malloc failure | xErrno_NoMemory |
On error, the xUrl struct is zeroed — no cleanup needed.
Best Practices
- Always check the return value of
xUrlParse(). On error the struct is zeroed, so accessing fields is safe but yields empty values. - Use
xUrlPort()instead of parsing the port string yourself. It handles default ports and validates the numeric range (0–65535). - Call
xUrlFree()when done. Forgetting to free leaks the internal string copy. - Don't cache field pointers past
xUrlFree(). All pointers become invalid after the free call.
Implementation Details
URL Format
scheme://[userinfo@]host[:port][/path][?query][#fragment]
Parsing Steps
flowchart TD
START["Input: raw URL string"]
SCHEME["Find '://' → extract scheme"]
AUTH["Parse authority section"]
USERINFO{"Contains '@'?"}
UI_YES["Extract userinfo"]
HOST{"Starts with '['?"}
IPV6["Parse IPv6 bracket literal"]
IPV4["Scan backwards for ':'"]
PORT["Extract port (if present)"]
PATH{"Starts with '/'?"}
PATH_YES["Extract path"]
QUERY{"Starts with '?'?"}
QUERY_YES["Extract query"]
FRAG{"Starts with '#'?"}
FRAG_YES["Extract fragment"]
DONE["Return xErrno_Ok"]
START --> SCHEME --> AUTH
AUTH --> USERINFO
USERINFO -->|Yes| UI_YES --> HOST
USERINFO -->|No| HOST
HOST -->|Yes| IPV6 --> PORT
HOST -->|No| IPV4 --> PORT
PORT --> PATH
PATH -->|Yes| PATH_YES --> QUERY
PATH -->|No| QUERY
QUERY -->|Yes| QUERY_YES --> FRAG
QUERY -->|No| FRAG
FRAG -->|Yes| FRAG_YES --> DONE
FRAG -->|No| DONE
style DONE fill:#50b86c,color:#fff
Memory Layout
xUrl struct (stack or heap):
┌──────────┬──────────────────────────────────┐
│ raw_ │→ strdup("https://host:443/path") │
│ scheme │→ ───────┘ │
│ host │→ ──────────────┘ │
│ port │→ ───────────────────┘ │
│ path │→ ────────────────────────┘ │
│ ... │ │
└──────────┴──────────────────────────────────┘
All pointers reference the single raw_ copy.
Operations and Complexity
| Operation | Complexity | Notes |
|---|---|---|
xUrlParse | O(n) | Single pass over the URL string |
xUrlPort | O(1) | Converts port string or returns default |
xUrlFree | O(1) | Frees the internal copy, zeroes struct |
dns.h — Asynchronous DNS Resolution
Introduction
dns.h provides asynchronous DNS resolution by offloading getaddrinfo() to the event loop's thread pool. The completion callback is always invoked on the event loop thread, maintaining libx's single-threaded callback model. Queries can be cancelled before the callback fires.
Design Philosophy
-
Thread-Pool Offload —
getaddrinfo()is a blocking POSIX call. Rather than introducing a dedicated DNS thread, xnet reuses the event loop's existing thread pool viaxEventLoopSubmit(). -
Event-Loop-Thread Callbacks — The done callback runs on the event loop thread, so user code never needs synchronization. This is consistent with every other callback in libx.
-
Linked-List Result — Resolved addresses are returned as a linked list of
xDnsAddrnodes, preserving the fullgetaddrinfo()result (family, socktype, protocol) for each address. -
Cancellation Support —
xDnsCancel()sets an atomic flag. If the worker has already finished, the done callback silently discards the result instead of invoking the user callback. -
IP Literal Fast Path — If the hostname is an IPv4 or IPv6 literal,
AI_NUMERICHOSTis set automatically, skipping the actual DNS lookup.
Architecture
sequenceDiagram
participant App as Application
participant EL as Event Loop Thread
participant TP as Thread Pool Worker
App->>EL: xDnsResolve(loop, "example.com", ...)
EL->>TP: xEventLoopSubmit(dns_work_fn)
Note over TP: getaddrinfo() (blocking)
TP-->>EL: dns_done_fn(result)
alt Not cancelled
EL->>App: callback(result, arg)
else Cancelled
EL->>EL: xDnsResultFree(result)
end
API Reference
Core Functions
| Function | Signature | Description |
|---|---|---|
xDnsResolve | xDnsQuery xDnsResolve(xEventLoop loop, const char *hostname, const char *service, const struct addrinfo *hints, xDnsCallback callback, void *arg) | Start async DNS resolution |
xDnsCancel | void xDnsCancel(xEventLoop loop, xDnsQuery query) | Cancel a pending query |
xDnsResultFree | void xDnsResultFree(xDnsResult *result) | Free a resolution result |
Types
| Type | Description |
|---|---|
xDnsQuery | Opaque handle to a pending query |
xDnsResult | Resolution result: error + addrs linked list |
xDnsAddr | Single resolved address node |
xDnsCallback | void (*)(xDnsResult *result, void *arg) |
xDnsResult Fields
| Field | Type | Description |
|---|---|---|
error | xErrno | xErrno_Ok on success |
addrs | xDnsAddr * | Linked list of addresses, or NULL |
xDnsAddr Fields
| Field | Type | Description |
|---|---|---|
addr | struct sockaddr_storage | Resolved socket address |
addrlen | socklen_t | Length of the address |
family | int | AF_INET or AF_INET6 |
socktype | int | SOCK_STREAM or SOCK_DGRAM |
protocol | int | IPPROTO_TCP or IPPROTO_UDP |
next | xDnsAddr * | Next address, or NULL |
Parameter Details for xDnsResolve
| Parameter | Required | Description |
|---|---|---|
loop | Yes | Event loop (must not be NULL) |
hostname | Yes | Hostname or IP literal (non-empty) |
service | No | Port string (e.g. "443") or NULL |
hints | No | addrinfo hints; NULL defaults to AF_UNSPEC + SOCK_STREAM |
callback | Yes | Completion callback (must not be NULL) |
arg | No | User argument forwarded to callback |
Returns a xDnsQuery handle, or NULL on invalid arguments.
Usage Examples
Basic Resolution
#include <stdio.h>
#include <arpa/inet.h>
#include <x/base/event.h>
#include <x/net/dns.h>
static void on_resolved(xDnsResult *result, void *arg) {
xEventLoop loop = (xEventLoop)arg;
if (result->error != xErrno_Ok) {
fprintf(stderr, "DNS failed: %d\n", result->error);
xDnsResultFree(result);
xEventLoopStop(loop);
return;
}
for (xDnsAddr *a = result->addrs; a; a = a->next) {
char buf[INET6_ADDRSTRLEN];
if (a->family == AF_INET) {
struct sockaddr_in *sin = (struct sockaddr_in *)&a->addr;
inet_ntop(AF_INET, &sin->sin_addr, buf, sizeof(buf));
} else {
struct sockaddr_in6 *sin6 = (struct sockaddr_in6 *)&a->addr;
inet_ntop(AF_INET6, &sin6->sin6_addr, buf, sizeof(buf));
}
printf(" %s (family=%d)\n", buf, a->family);
}
xDnsResultFree(result);
xEventLoopStop(loop);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xDnsResolve(loop, "example.com", "443", NULL, on_resolved, loop);
xEventLoopRun(loop);
xEventLoopDestroy(loop);
return 0;
}
IPv4-Only Resolution
struct addrinfo hints = {0};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
xDnsResolve(loop, "example.com", "80", &hints, on_resolved, loop);```
### Cancelling a Query
```c
xDnsQuery q = xDnsResolve(loop, "slow.example.com", NULL, NULL, on_resolved, NULL);
// Cancel immediately — callback will NOT fire
xDnsCancel(loop, q);
IP Literal (No DNS Lookup)
// Resolves instantly via AI_NUMERICHOST
xDnsResolve(loop, "127.0.0.1", "8080", NULL, on_resolved, loop);
xDnsResolve(loop, "::1", "8080", NULL, on_resolved, loop);
Thread Safety
| Operation | Thread Safety |
|---|---|
xDnsResolve() | Call from event loop thread only |
xDnsCancel() | Call from event loop thread only |
xDnsResultFree() | Call from any thread (result is owned) |
xDnsCallback | Always invoked on event loop thread |
Error Handling
| Scenario | Behavior |
|---|---|
NULL loop, hostname, or callback | Returns NULL (no query created) |
| Empty hostname | Returns NULL |
malloc failure | Returns NULL |
getaddrinfo() failure | Callback receives result->error != xErrno_Ok |
| Cancelled query | Callback is not invoked; result is freed internally |
Best Practices
- Always call
xDnsResultFree()in your callback. The callee owns the result. - Check
result->errorbefore iteratingaddrs. On failure,addrsisNULL. - Use
xDnsCancel()for cleanup. If you destroy the object that owns the callback context, cancel the query first to prevent a use-after-free. - Pass
NULLhints for typical use. The defaults (AF_UNSPEC+SOCK_STREAM) cover most HTTP/WebSocket connection scenarios. xDnsCancel(loop, NULL)is safe — it's a no-op, so you don't need to guard against NULL handles.
Implementation Details
Internal Request Lifecycle
stateDiagram-v2
[*] --> Created: xDnsResolve()
Created --> Queued: xEventLoopSubmit()
Queued --> Working: Thread pool picks up
Working --> Done: getaddrinfo() returns
Done --> Delivered: callback invoked
Done --> Discarded: cancelled flag set
Queued --> Cancelled: xDnsCancel()
Working --> Cancelled: xDnsCancel()
Cancelled --> Discarded: done_fn checks flag
Delivered --> [*]: request freed
Discarded --> [*]: request freed
Error Mapping
getaddrinfo() returns EAI_* codes. These are mapped to libx error codes:
| EAI Code | xErrno | Meaning |
|---|---|---|
0 (success) | xErrno_Ok | Resolution succeeded |
EAI_NONAME | xErrno_DnsNotFound | Host not found |
EAI_AGAIN | xErrno_DnsTempFail | Temporary failure |
EAI_MEMORY | xErrno_NoMemory | Out of memory |
| Other | xErrno_DnsError | Generic DNS error |
IP Literal Detection
Before calling getaddrinfo(), the worker checks if the hostname is an IP literal using inet_pton(). If it is, AI_NUMERICHOST is added to the hints, which tells getaddrinfo() to skip DNS lookup entirely.
// Pseudocode
if (inet_pton(AF_INET, hostname, buf) == 1 ||
inet_pton(AF_INET6, hostname, buf) == 1) {
hints.ai_flags |= AI_NUMERICHOST;
}
tcp.h — Async TCP Connection, Connector & Listener
Introduction
tcp.h provides three async TCP building blocks on top of libx's event loop:
- xTcpConn — a thin resource wrapper that pairs an
xSocketwith anxTransport, plus convenienceRecv/Send/SendIovhelpers. - xTcpConnect — an async connector that performs DNS → socket → non-blocking connect → optional TLS handshake, delivering a ready-to-use
xTcpConnvia callback. - xTcpListener — an async listener that accepts connections (with optional TLS) and delivers each as an
xTcpConn.
All callbacks run on the event loop thread, consistent with the rest of libx.
Design Philosophy
-
Resource Wrapper, Not Callback Framework — Unlike
xWsCallbacks, we intentionally do not provideon_data/on_closecallbacks at the TCP layer. WebSocket callbacks work well because the protocol defines message boundaries, close handshakes, and ping/pong — the library does real work before invoking user code. Raw TCP is a byte stream with no framing; anon_datacallback would still deliver arbitrary fragments, leaving the user to reassemble and parse — no better than callingxTcpConnRecvdirectly. Instead, users register their ownxSocketFunccallback viaxSocketSetCallback()and drive I/O withxTcpConnRecv/xTcpConnSend. -
Transport Transparency —
xTcpConnwraps anxTransportvtable. For plain TCP,read/writevmap toread(2)/writev(2). For TLS, they map toSSL_read/SSL_write. TheRecv/Send/SendIovhelpers hide this detail so users never need to reach intoxTransportinternals. -
Full Async Connector Pipeline —
xTcpConnectchains DNS resolution → socket creation → non-blockingconnect()→ optional TLS handshake into a single async operation with a timeout. Each phase is driven by event loop callbacks. -
Ownership Transfer —
xTcpConnTakeSocketandxTcpConnTakeTransportallow higher-level protocols (e.g. WebSocket upgrade) to extract the underlying resources without closing them.
Architecture
Connector State Machine
stateDiagram-v2
[*] --> DNS: xTcpConnect()
DNS --> TcpConnect: resolved
DNS --> Failed: DNS error
TcpConnect --> TlsHandshake: connected + TLS configured
TcpConnect --> Succeed: connected (plain TCP)
TcpConnect --> Failed: connect error
TlsHandshake --> Succeed: handshake done
TlsHandshake --> Failed: handshake error
Succeed --> [*]: callback(conn, Ok)
Failed --> [*]: callback(NULL, err)
note right of DNS: Async via xDnsResolve
note right of TcpConnect: Non-blocking connect()
note right of TlsHandshake: Async SSL_do_handshake
Listener Accept Flow
sequenceDiagram
participant EL as Event Loop
participant L as xTcpListener
participant PC as PendingConn (TLS only)
participant App as User Callback
EL->>L: xEvent_Read (new connection)
L->>L: accept()
alt Plain TCP
L->>App: callback(listener, conn, addr)
else TLS
L->>PC: create PendingConn
loop Handshake rounds
EL->>PC: xEvent_Read / xEvent_Write
PC->>PC: SSL_do_handshake()
end
PC->>App: callback(listener, conn, addr)
end
xTcpConn Resource Ownership
graph LR
CONN["xTcpConn"]
SOCK["xSocket<br/>(event loop registration)"]
TP["xTransport<br/>(plain / TLS vtable)"]
FD["fd"]
CONN --> SOCK
CONN --> TP
SOCK --> FD
style CONN fill:#4a90d9,color:#fff
style SOCK fill:#50b86c,color:#fff
style TP fill:#f5a623,color:#fff
xTcpConnClose() destroys in order: transport → socket → conn shell. Use xTcpConnTakeSocket() / xTcpConnTakeTransport() to extract resources before closing.
API Reference
xTcpConn — Connection
| Function | Signature | Description |
|---|---|---|
xTcpConnRecv | ssize_t xTcpConnRecv(xTcpConn conn, void *buf, size_t len) | Read up to len bytes; returns bytes read, 0 on EOF, -1 on error |
xTcpConnSend | ssize_t xTcpConnSend(xTcpConn conn, const char *buf, size_t len) | Write len bytes; returns bytes written, -1 on error |
xTcpConnSendIov | ssize_t xTcpConnSendIov(xTcpConn conn, const struct iovec *iov, int iovcnt) | Scatter-gather write; returns total bytes written, -1 on error |
xTcpConnTransport | xTransport *xTcpConnTransport(xTcpConn conn) | Get the internal transport vtable |
xTcpConnSocket | xSocket xTcpConnSocket(xTcpConn conn) | Get the underlying socket handle |
xTcpConnTakeSocket | xSocket xTcpConnTakeSocket(xTcpConn conn) | Extract socket ownership (conn no longer owns it) |
xTcpConnTakeTransport | xTransport xTcpConnTakeTransport(xTcpConn conn) | Extract transport ownership (conn no longer owns it) |
xTcpConnReader | xReader xTcpConnReader(xTcpConn conn) | Get an xReader adapter bound to the connection's transport (see io.h) |
xTcpConnWriter | xWriter xTcpConnWriter(xTcpConn conn) | Get an xWriter adapter bound to the connection's transport (see io.h) |
xTcpConnClose | void xTcpConnClose(xTcpConn conn) | Close connection and free all resources |
xTcpConnect — Async Connector
| Function | Signature | Description |
|---|---|---|
xTcpConnect | xErrno xTcpConnect(const char *host, uint16_t port, const xTcpConnectConf *conf, xTcpConnectFunc callback, void *arg) | Initiate async TCP connection |
xTcpConnectConf Fields
| Field | Type | Default | Description |
|---|---|---|---|
tls_ctx | xTlsCtx | NULL | Pre-created shared TLS context (preferred); NULL for plain TCP or auto-create from tls |
tls | const xTlsConf * | NULL | TLS config for auto-created ctx; ignored when tls_ctx is set; NULL for plain TCP |
timeout_ms | int | 10000 | Connect timeout in milliseconds |
nodelay | int | 0 | Set TCP_NODELAY if non-zero |
keepalive | int | 0 | Set SO_KEEPALIVE if non-zero |
TLS context resolution order: tls_ctx (shared, not owned) → auto-create from tls → defaults (system CA, verify enabled). When tls_ctx is provided, the connector does not create or destroy the context — the caller retains ownership.
xTcpConnectFunc
typedef void (*xTcpConnectFunc)(xTcpConn conn, xErrno err, void *arg);
On success: conn is valid, err is xErrno_Ok. On failure: conn is NULL, err indicates the error.
xTcpListener — Async Listener
| Function | Signature | Description |
|---|---|---|
xTcpListenerCreate | xTcpListener xTcpListenerCreate(const char *host, uint16_t port, const xTcpListenerConf *conf, xTcpListenerFunc callback, void *arg) | Create and start a TCP listener |
xTcpListenerDestroy | void xTcpListenerDestroy(xTcpListener listener) | Stop listening and free resources |
xTcpListenerConf Fields
| Field | Type | Default | Description |
|---|---|---|---|
tls_ctx | xTlsCtx | NULL | TLS context from xTlsCtxCreate(); NULL for plain TCP |
backlog | int | 128 | listen() backlog |
reuseport | int | 0 | Set SO_REUSEPORT if non-zero |
xTcpListenerFunc
typedef void (*xTcpListenerFunc)(xTcpListener listener, xTcpConn conn,
const struct sockaddr *addr, socklen_t addrlen,
void *arg);
Invoked for each accepted connection. The callee takes ownership of conn.
Usage Examples
Echo Server
#include <string.h>
#include <x/base/event.h>
#include <x/base/socket.h>
#include <x/net/tcp.h>
static void on_conn_event(xSocket sock, xEventMask mask, void *arg) {
xTcpConn conn = (xTcpConn)arg;
(void)sock;
if (mask & xEvent_Read) {
char buf[4096];
ssize_t n = xTcpConnRecv(conn, buf, sizeof(buf));
if (n > 0) {
xTcpConnSend(conn, buf, (size_t)n);
} else {
/* EOF or error: close */
xTcpConnClose(conn);
}
}
}
static void on_accept(xTcpListener listener, xTcpConn conn,
const struct sockaddr *addr, socklen_t addrlen,
void *arg) {
(void)listener; (void)addr; (void)addrlen; (void)arg;
/* Register our own event callback on the connection's socket */
xSocket sock = xTcpConnSocket(conn);
xSocketSetCallback(sock, on_conn_event, conn);
/* Socket is already registered for xEvent_Read by default */
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xTcpListener listener =
xTcpListenerCreate("0.0.0.0", 8080, NULL, on_accept, NULL);
if (!listener) return 1;
xEventLoopRun(loop);
xTcpListenerDestroy(listener);
xEventLoopDestroy(loop);
return 0;
}
Async Client
#include <stdio.h>
#include <string.h>
#include <x/base/event.h>
#include <x/base/socket.h>
#include <x/net/tcp.h>
static void on_response(xSocket sock, xEventMask mask, void *arg) {
xTcpConn conn = (xTcpConn)arg;
xEventLoop loop = (xEventLoop)xSocketLoop(sock);
(void)mask;
char buf[4096];
ssize_t n = xTcpConnRecv(conn, buf, sizeof(buf));
if (n > 0) {
printf("Received: %.*s\n", (int)n, buf);
}
xTcpConnClose(conn);
xEventLoopStop(loop);
}
static void on_connected(xTcpConn conn, xErrno err, void *arg) {
xEventLoop loop = (xEventLoop)arg;
if (err != xErrno_Ok) {
fprintf(stderr, "Connect failed: %d\n", err);
xEventLoopStop(loop);
return;
}
/* Send a request */
const char *msg = "Hello, server!";
xTcpConnSend(conn, msg, strlen(msg));
/* Wait for response */
xSocket sock = xTcpConnSocket(conn);
xSocketSetCallback(sock, on_response, conn);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xTcpConnectConf conf = {0};
conf.nodelay = 1;
xTcpConnect("127.0.0.1", 8080, &conf, on_connected, loop);
xEventLoopRun(loop);
xEventLoopDestroy(loop);
return 0;
}
TLS Client (auto-create context)
#include <x/net/tcp.h>
#include <x/net/tls.h>
static void on_tls_connected(xTcpConn conn, xErrno err, void *arg) {
if (err != xErrno_Ok) { /* handle error */ return; }
/* TLS is already established — Recv/Send are transparently encrypted */
const char *msg = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
xTcpConnSend(conn, msg, strlen(msg));
/* ... register read callback ... */
}
void connect_tls(xEventLoop loop) {
xTlsConf tls = {0};
tls.ca = "/etc/ssl/certs/ca-certificates.crt";
xTcpConnectConf conf = {0};
conf.tls = &tls;
xTcpConnect("example.com", 443, &conf, on_tls_connected, loop);
}
TLS Client (shared context)
When making many connections to the same server, share a xTlsCtx to avoid reloading certificates each time:
#include <x/net/tcp.h>
#include <x/net/tls.h>
static void on_connected(xTcpConn conn, xErrno err, void *arg) {
if (err != xErrno_Ok) { /* handle error */ return; }
/* ... use conn ... */
}
void connect_with_shared_ctx(xEventLoop loop) {
// Create once, reuse for all connections
xTlsConf tls = {0};
tls.ca = "ca.pem";
xTlsCtx ctx = xTlsCtxCreate(&tls);
xTcpConnectConf conf = {0};
conf.tls_ctx = ctx; // shared, not owned by connector
xTcpConnect("example.com", 443, &conf, on_connected, loop);
xTcpConnect("example.com", 443, &conf, on_connected, loop);
// ... later, after all connections are closed ...
xTlsCtxDestroy(ctx);
}
TLS Server
#include <x/net/tcp.h>
#include <x/net/transport.h>
void start_tls_server(xEventLoop loop) {
xTlsConf tls_conf = {
.cert = "server.pem",
.key = "server-key.pem",
};
xTlsCtx tls_ctx = xTlsCtxCreate(&tls_conf);
xTcpListenerConf conf = {0};
conf.tls_ctx = tls_ctx;
xTcpListener listener =
xTcpListenerCreate("0.0.0.0", 8443, &conf, on_accept, NULL);
/* ... run event loop ... */
xTcpListenerDestroy(listener);
xTlsCtxDestroy(tls_ctx);
}
Ownership Transfer (Protocol Upgrade)
/* After receiving an HTTP upgrade response on a TCP connection,
* extract the socket and transport for the new protocol layer. */
xSocket sock = xTcpConnTakeSocket(conn);
xTransport tp = xTcpConnTakeTransport(conn);
/* Close the empty conn shell (no-op on resources) */
xTcpConnClose(conn);
/* sock and tp are now owned by the new protocol handler */
Thread Safety
| Operation | Thread Safety |
|---|---|
xTcpConnect() | Call from event loop thread only |
xTcpListenerCreate() | Call from event loop thread only |
xTcpListenerDestroy() | Call from event loop thread only |
xTcpConnRecv/Send/SendIov() | Call from event loop thread only |
xTcpConnClose() | Call from event loop thread only |
xTcpConnectFunc callback | Always invoked on event loop thread |
xTcpListenerFunc callback | Always invoked on event loop thread |
Error Handling
| Scenario | Behavior |
|---|---|
NULL loop, host, or callback in xTcpConnect | Returns xErrno_InvalidArg |
| DNS resolution failure | Callback receives xErrno_DnsError or xErrno_DnsNotFound |
connect() failure | Callback receives xErrno_SysError |
| TLS handshake failure | Callback receives xErrno_SysError |
| Connect timeout | Callback receives xErrno_Timeout |
xTcpListenerCreate bind/listen failure | Returns NULL |
xTcpConnRecv/Send on NULL conn | Returns -1 |
xTcpConnClose(NULL) | No-op (safe) |
xTcpListenerDestroy(NULL) | No-op (safe) |
Best Practices
- Always close connections with
xTcpConnClose()— it destroys the transport (TLS cleanup), removes the socket from the event loop, closes the fd, and frees the conn. - Register your own
xSocketFuncon the connection's socket viaxSocketSetCallback()to receive read/write events, then usexTcpConnRecv/xTcpConnSendinside the callback. - Use
xTcpConnSendIovfor multi-buffer writes (e.g. header + body) to avoid copying into a single buffer. - Set
nodelay = 1inxTcpConnectConffor latency-sensitive protocols (HTTP, WebSocket). - Use
xTcpConnTakeSocket/xTcpConnTakeTransportwhen upgrading protocols (e.g. HTTP → WebSocket) to avoid double-free. - Cancel or close before freeing context — if you destroy the object that owns the connect callback context, ensure the connection attempt has completed or timed out first.
tls.h — TLS Configuration Types
Introduction
tls.h defines xTlsConf, the unified TLS configuration structure shared across libx modules, and xTlsCtx, the opaque handle to a server-level TLS context. It controls certificate loading, peer verification, and optional ALPN negotiation for both client-side and server-side TLS. These are the central TLS abstractions — the actual TLS handshake is handled by the TLS backend (OpenSSL or mbedTLS) in the transport layer.
Design Philosophy
-
Backend-Agnostic — The config struct contains only file paths and flags. It works identically whether the TLS backend is OpenSSL or mbedTLS.
-
Zero-Initialize for Defaults — A zero-initialized
xTlsConfuses the system CA bundle with full peer and host verification enabled. This is the secure default for both client and server. -
Unified Client/Server — A single
xTlsConfstruct serves both roles. Client-only fields (key_password) and server-only fields (alpn) are simply left asNULL/ zero when unused. -
Separation of Concerns — TLS configuration is defined in xnet (the networking primitives layer) and consumed by xhttp (the HTTP layer). This avoids circular dependencies and allows future modules to reuse the same types.
API Reference
xTlsConf
Unified TLS configuration for both client and server.
| Field | Type | Default | Description |
|---|---|---|---|
cert | const char * | NULL (none) | Path to PEM certificate file |
key | const char * | NULL (none) | Path to PEM private key file |
ca | const char * | NULL (system CA) | Path to CA certificate file |
key_password | const char * | NULL (none) | Private key password (client-side) |
alpn | const char ** | NULL (none) | NULL-terminated ALPN protocol list (server-side) |
skip_verify | int | 0 (verify) | Non-zero to skip peer & host verification |
Backward-compatible aliases: xTlsClientConf and xTlsServerConf are typedef'd to xTlsConf.
xTlsCtx
Opaque handle to a shared TLS context. Created by xTlsCtxCreate(), used by both server-side listeners (xTcpListenerConf.tls_ctx) and client-side connectors (xTcpConnectConf.tls_ctx, xWsConnectConf.tls_ctx). Shared across all connections that use the same context. Destroyed by xTlsCtxDestroy(). Supports certificate hot-reload via xTlsCtxReload().
xTlsCtxCreate
xTlsCtx xTlsCtxCreate(const xTlsConf *conf);
Create a shared TLS context. Loads the certificate (if provided), private key (if provided), optional CA, and optional ALPN list. The returned context can be shared across all connections that use the same TLS configuration.
conf— TLS configuration (must not be NULL). For server-side use,certandkeyare required. For client-side use, onlyca(or defaults) is needed.- Returns a TLS context handle, or
NULLon failure.
xTlsCtxDestroy
void xTlsCtxDestroy(xTlsCtx ctx);
Destroy a shared TLS context and release all resources. Safe to call with NULL (no-op). Must only be called after all connections using this context have been closed.
xTlsCtxReload
int xTlsCtxReload(xTlsCtx ctx, const xTlsConf *conf);
Hot-reload certificates for an existing TLS context. Atomically replaces the certificate, private key, and optional CA. Existing connections are not affected; only new connections will use the updated certificates.
ctx— TLS context to reload (must not be NULL).conf— New TLS configuration (must not be NULL,certandkeymust not be NULL).- Returns
0on success,-1on failure (context unchanged).
Example: Certificate hot-reload
// Initial setup
xTlsConf tls = {
.cert = "server.pem",
.key = "server-key.pem",
.alpn = (const char *[]){"h2", "http/1.1", NULL},
};
xTlsCtx ctx = xTlsCtxCreate(&tls);
// ... later, when certificates are renewed ...
xTlsConf new_tls = {
.cert = "server-new.pem",
.key = "server-key-new.pem",
.alpn = (const char *[]){"h2", "http/1.1", NULL},
};
if (xTlsCtxReload(ctx, &new_tls) == 0) {
// New connections will use the updated certificates
}
One-Way TLS (Client Verifies Server)
#include <x/net/tls.h>
#include <x/http/client.h>
// Use system CA bundle (zero-init)
xTlsConf tls = {0};
xHttpClientConf conf = {.tls = &tls};
xHttpClient client = xHttpClientCreate(&conf);
// Or specify a CA file
xTlsConf tls_ca = {0};
tls_ca.ca = "ca.pem";
xHttpClientConf conf_ca = {.tls = &tls_ca};
xHttpClient client2 = xHttpClientCreate(&conf_ca);
Skip Verification (Development Only)
xTlsConf tls = {0};
tls.skip_verify = 1; // DANGER: disables all checks
xHttpClientConf conf = {.tls = &tls};
xHttpClient client = xHttpClientCreate(&conf);
Mutual TLS (mTLS)
// Server: require client certificate (default: verify enabled)
xTlsConf server_tls = {
.cert = "server.pem",
.key = "server-key.pem",
.ca = "ca.pem",
};
xHttpServerListenTls(server, "0.0.0.0", 8443, &server_tls);
// Client: present certificate
xTlsConf client_tls = {0};
client_tls.ca = "ca.pem";
client_tls.cert = "client.pem";
client_tls.key = "client-key.pem";
xHttpClientConf client_conf = {
.tls = &client_tls,
};
xHttpClient client = xHttpClientCreate(&client_conf);
Password-Protected Private Key
xTlsConf tls = {0};
tls.ca = "ca.pem";
tls.cert = "client.pem";
tls.key = "client-key-enc.pem";
tls.key_password = "my-secret";
xHttpClientConf conf = {.tls = &tls};
xHttpClient client = xHttpClientCreate(&conf);
Relationship with Other Modules
- xnet —
xTlsCtxCreate()/xTlsCtxDestroy()/xTlsCtxReload()are declared intls.hand implemented in the TLS backend files (transport_openssl.c,transport_mbedtls.c). The TCP listener usesxTlsCtxviaxTcpListenerConf.tls_ctx, and the TCP connector uses it viaxTcpConnectConf.tls_ctx. - xhttp — The HTTP server calls
xTlsCtxCreate()internally whenxHttpServerListenTls()is invoked, automatically setting ALPN to{"h2", "http/1.1"}. The HTTP client uses libcurl for TLS management and consumesxTlsConfdirectly. The WebSocket client supports bothxTlsConf(auto-creates a context) and a pre-createdxTlsCtx(shared across connections) viaxWsConnectConf.tls_ctx. See the TLS Deployment Guide for end-to-end examples.
Security Notes
- Never use
skip_verify = 1in production. It disables all certificate validation. - Keep private keys secure. Use restrictive file permissions (
chmod 600). - For mTLS, set
cato the signing CA on the server side. Zero-initializedskip_verifymeans verification is enabled by default. - The config struct does not copy strings. The caller must ensure that file path strings remain valid until
xHttpClientCreate()orxHttpServerListenTls()returns (the library deep-copies them internally).
xlog — Async Logging
Introduction
xlog is libx's high-performance asynchronous logging module. It formats log entries on the calling thread and flushes them to a file (or stderr) on the event loop thread, decoupling I/O latency from application logic. Three operating modes — Timer, Notify, and Mixed — offer different trade-offs between flush latency and overhead.
Design Philosophy
-
Async by Default — Log messages are formatted on the calling thread and enqueued via a lock-free MPSC queue. The event loop thread drains the queue and writes to disk, ensuring that logging never blocks the caller (except for Fatal level).
-
Three Modes for Different Needs — Timer mode batches writes for throughput; Notify mode uses a pipe for low-latency delivery; Mixed mode combines both, using the timer for normal messages and the pipe for high-severity entries.
-
Event Loop Integration — The logger is bound to an
xEventLoopand uses its timer and I/O facilities. This means no dedicated logging thread — the event loop thread handles both I/O and log flushing. -
Thread-Local Context —
xLoggerEnter()sets the current thread's logger, enabling theXLOG_*()macros and bridging xbase's internalxLog()calls to the async pipeline.
Architecture
graph TD
subgraph "Application Threads"
T1["Thread 1<br/>xLoggerLog()"]
T2["Thread 2<br/>XLOG_INFO()"]
T3["Thread 3<br/>xLog() (xbase internal)"]
end
subgraph "Lock-Free Queue"
MPSC["MPSC Queue<br/>(xbase/mpsc.h)"]
end
subgraph "Event Loop Thread"
TIMER["Timer Callback<br/>(periodic flush)"]
PIPE["Pipe Callback<br/>(immediate flush)"]
FLUSH["logger_flush_entries()"]
WRITE["fwrite() + fflush()"]
ROTATE["File Rotation"]
end
subgraph "Output"
FILE["Log File"]
STDERR["stderr"]
end
T1 -->|"format + enqueue"| MPSC
T2 -->|"format + enqueue"| MPSC
T3 -->|"bridge_callback"| MPSC
MPSC --> FLUSH
TIMER --> FLUSH
PIPE --> FLUSH
FLUSH --> WRITE
WRITE --> FILE
WRITE --> STDERR
WRITE -->|"max_size exceeded"| ROTATE
style MPSC fill:#f5a623,color:#fff
style FLUSH fill:#50b86c,color:#fff
Sub-Module Overview
| File | Description | Doc |
|---|---|---|
logger.h | Async logger API, macros, and configuration | logger.md |
Quick Start
#include <x/base/event.h>
#include <x/log/logger.h>
int main(void) {
xEventLoop loop = xEventLoopCreate();
xLoggerConf conf = {
.loop = loop,
.path = "app.log",
.mode = xLogMode_Mixed,
.level = xLogLevel_Info,
.max_size = 10 * 1024 * 1024, // 10MB
.max_files = 5,
.flush_interval_ms = 100,
};
xLogger logger = xLoggerCreate(conf);
xLoggerEnter(logger); // Set as thread-local logger
XLOG_INFO("Application started, version %d.%d", 1, 0);
XLOG_WARN("Low memory: %zu bytes remaining", (size_t)1024);
// Run event loop (processes log flushes)
xEventLoopRun(loop);
xLoggerLeave();
xLoggerDestroy(logger);
xEventLoopDestroy(loop);
return 0;
}
Relationship with Other Modules
- xbase/event.h — The logger is bound to an
xEventLoopfor timer-driven and pipe-driven flush. - xbase/mpsc.h — Uses the lock-free
MPSC queueto pass log entries from producer threads to the event loop thread. - xbase/log.h —
xLoggerEnter()bridges xbase's internalxLog()calls to the async logger via the thread-local callback mechanism. - xbase/atomic.h — Uses
atomic operationsfor the lock-free entry freelist.
logger.h — High-Performance Async Logger
Introduction
logger.h provides xLogger, a high-performance asynchronous logger that formats log entries on the calling thread and flushes them to a file (or stderr) on the event loop thread. It supports three operating modes (Timer, Notify, Mixed), five severity levels, file rotation, synchronous flush, and seamless bridging with xbase's internal xLog() mechanism.
Design Philosophy
-
Format on Caller, Write on Loop — Log messages are formatted (
snprintf) on the calling thread into a pre-allocated entry buffer, then enqueued via the lock-free MPSC queue. The event loop thread dequeues and writes to disk. This decouples I/O latency from application logic. -
Three Operating Modes — Different applications have different latency/throughput requirements:
- Timer — Periodic flush (default 100ms). Best throughput, highest latency.
- Notify — Pipe-based immediate notification. Lowest latency, highest overhead.
- Mixed — Timer for normal messages, pipe for Error/Fatal. Best balance.
-
Lock-Free Entry Pool — A global Treiber stack freelist recycles log entry structs across all threads, avoiding
malloc/freeon the hot path. -
Fatal = Synchronous + Abort — Fatal-level messages bypass the async queue entirely: they are written directly to the file and followed by
abort(). This ensures the fatal message is never lost. -
xbase Bridge —
xLoggerEnter()registers a callback with xbase'sxLogSetCallback(), routing all internal libx error messages through the async logger.
Architecture
graph TD
subgraph "xLogger Internal"
MPSC["MPSC Queue<br/>(head, tail)"]
TIMER["xEventLoopTimer<br/>(periodic flush)"]
PIPE["Pipe<br/>(notify flush)"]
FLUSH_PIPE["Flush Request Pipe<br/>(sync flush)"]
FREELIST["Entry Freelist<br/>(Treiber stack)"]
FP["FILE *fp<br/>(log file or stderr)"]
end
subgraph "xbase Dependencies"
EVENT["xEventLoop"]
MPSC_LIB["xbase/mpsc.h"]
ATOMIC_LIB["xbase/atomic.h"]
LOG_LIB["xbase/log.h"]
end
TIMER --> EVENT
PIPE --> EVENT
FLUSH_PIPE --> EVENT
MPSC --> MPSC_LIB
FREELIST --> ATOMIC_LIB
style MPSC fill:#f5a623,color:#fff
style FREELIST fill:#4a90d9,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xLogger | Opaque handle to an async logger |
xLogLevel | Enum: Debug, Info, Warn, Error, Fatal |
xLogMode | Enum: Timer, Notify, Mixed |
xLoggerConf | Configuration struct for creating a logger |
xLoggerConf Fields
| Field | Type | Default | Description |
|---|---|---|---|
loop | xEventLoop | (required) | Event loop for timer/pipe callbacks |
path | const char * | NULL (stderr) | Log file path |
mode | xLogMode | Timer | Operating mode |
level | xLogLevel | Info | Minimum log level |
max_size | size_t | 0 (no rotation) | Max file size before rotation |
max_files | int | 0 (no rotation) | Total files to keep (including current) |
flush_interval_ms | uint64_t | 100 | Timer/Mixed flush interval |
Functions
| Function | Signature | Description | Thread Safety |
|---|---|---|---|
xLoggerCreate | xLogger xLoggerCreate(xLoggerConf conf) | Create a logger. | Not thread-safe |
xLoggerDestroy | void xLoggerDestroy(xLogger logger) | Flush remaining entries and destroy. | Not thread-safe |
xLoggerLog | void xLoggerLog(xLogger logger, xLogLevel level, const char *fmt, ...) | Write a log entry. Fatal is synchronous + abort. | Thread-safe |
xLoggerFlush | void xLoggerFlush(xLogger logger) | Synchronously flush all pending entries. | Thread-safe |
xLoggerEnter | void xLoggerEnter(xLogger logger) | Set as thread-local logger + bridge xbase log. | Thread-local |
xLoggerLeave | void xLoggerLeave(void) | Clear thread-local logger. | Thread-local |
xLoggerCurrent | xLogger xLoggerCurrent(void) | Get current thread's logger. | Thread-local |
Convenience Macros
Using thread-local logger (set via xLoggerEnter):
| Macro | Expands To |
|---|---|
XLOG_DEBUG(fmt, ...) | xLoggerLog(xLoggerCurrent(), xLogLevel_Debug, fmt, ...) |
XLOG_INFO(fmt, ...) | xLoggerLog(xLoggerCurrent(), xLogLevel_Info, fmt, ...) |
XLOG_WARN(fmt, ...) | xLoggerLog(xLoggerCurrent(), xLogLevel_Warn, fmt, ...) |
XLOG_ERROR(fmt, ...) | xLoggerLog(xLoggerCurrent(), xLogLevel_Error, fmt, ...) |
XLOG_FATAL(fmt, ...) | xLoggerLog(xLoggerCurrent(), xLogLevel_Fatal, fmt, ...) |
Explicit logger variants: XLOG_DEBUG_L(logger, fmt, ...), etc.
Usage Examples
Basic File Logging
#include <x/base/event.h>
#include <x/log/logger.h>
int main(void) {
xEventLoop loop = xEventLoopCreate();
xLoggerConf conf = {
.loop = loop,
.path = "app.log",
.mode = xLogMode_Timer,
.level = xLogLevel_Info,
};
xLogger logger = xLoggerCreate(conf);
xLoggerEnter(logger);
XLOG_INFO("Server started on port %d", 8080);
XLOG_DEBUG("This is filtered out (level < Info)");
XLOG_WARN("Connection pool at %d%% capacity", 85);
xEventLoopRun(loop);
xLoggerLeave();
xLoggerDestroy(logger);
xEventLoopDestroy(loop);
return 0;
}
File Rotation Example
xLoggerConf conf = {
.loop = loop,
.path = "/var/log/myapp.log",
.mode = xLogMode_Mixed,
.level = xLogLevel_Info,
.max_size = 50 * 1024 * 1024, // 50MB per file
.max_files = 10, // Keep 10 files (500MB total)
};
Multi-Threaded Logging
#include <pthread.h>
#include <x/log/logger.h>
static xLogger g_logger;
static void *worker(void *arg) {
int id = *(int *)arg;
xLoggerEnter(g_logger); // Each thread must enter
for (int i = 0; i < 1000; i++) {
XLOG_INFO("Worker %d: iteration %d", id, i);
}
xLoggerLeave();
return NULL;
}
// In main():
// g_logger = xLoggerCreate(conf);
// pthread_create(&threads[i], NULL, worker, &ids[i]);
Synchronous Flush Before Exit
void graceful_shutdown(xLogger logger) {
XLOG_INFO("Shutting down...");
xLoggerFlush(logger); // Block until all entries are written
xLoggerDestroy(logger);
}
Use Cases
-
Application Logging — Primary use case: structured, async logging for server applications with file rotation and level filtering.
-
libx Internal Error Capture — Via
xLoggerEnter(), all libx internal errors (fromxLog()) are automatically routed through the async logger. -
Debug Logging — Use
xLogMode_Notifyduring development for immediate log output without timer delay.
Best Practices
- Call
xLoggerEnter()on every thread that usesXLOG_*()macros. Each thread needs its own thread-local context. - Use Mixed mode for production. It provides the best balance: batched writes for normal messages, immediate notification for errors.
- Set appropriate rotation limits. Without rotation (
max_size = 0), log files grow unbounded. - Call
xLoggerFlush()before shutdown to ensure all pending messages are written. - Don't log in tight loops at Debug level without checking the level first. While the level filter is cheap, formatting still costs CPU.
- Fatal messages are synchronous.
XLOG_FATAL()writes directly and callsabort(). Don't rely on async delivery for fatal messages.
Comparison with Other Libraries
| Feature | xlog logger.h | spdlog | zlog | log4c |
|---|---|---|---|---|
| Language | C99 | C++11 | C | C |
| Async Model | MPSC queue + event loop | Dedicated thread + queue | Dedicated thread | Synchronous |
| Modes | Timer / Notify / Mixed | Async (thread pool) | Async (thread) | Sync only |
| Lock-Free | Yes (MPSC + Treiber stack) | Yes (MPMC queue) | No (mutex) | No (mutex) |
| Event Loop | Integrated (xEventLoop) | None (own thread) | None (own thread) | None |
| File Rotation | Size-based (cascade rename) | Size-based | Size/time-based | Size-based |
| Format | printf-style | fmt-style / printf | printf-style | printf-style |
| Thread-Local Context | Yes (xLoggerEnter) | No | Yes (MDC) | Yes (NDC) |
| Fatal Handling | Sync write + abort | Flush + abort | Configurable | Configurable |
Key Differentiator: xlog is unique in integrating with an event loop rather than spawning a dedicated logging thread. This means the same thread that handles network I/O also handles log flushing, reducing context switches and thread count. The three-mode design (Timer/Notify/Mixed) gives fine-grained control over the latency/throughput trade-off that most logging libraries don't offer.
Implementation Details
Three Operating Modes
graph LR
subgraph "Timer Mode"
T_ENQUEUE["Enqueue"] --> T_TIMER["Timer fires<br/>(every 100ms)"]
T_TIMER --> T_FLUSH["Flush all entries"]
end
subgraph "Notify Mode"
N_ENQUEUE["Enqueue"] --> N_PIPE["Write 1 byte to pipe"]
N_PIPE --> N_LOOP["Pipe readable event"]
N_LOOP --> N_FLUSH["Flush all entries"]
end
subgraph "Mixed Mode"
M_ENQUEUE["Enqueue"]
M_ENQUEUE -->|"Debug/Info/Warn"| M_TIMER["Timer fires"]
M_ENQUEUE -->|"Error/Fatal"| M_PIPE["Write to pipe"]
M_TIMER --> M_FLUSH["Flush all entries"]
M_PIPE --> M_FLUSH
end
style T_FLUSH fill:#50b86c,color:#fff
style N_FLUSH fill:#50b86c,color:#fff
style M_FLUSH fill:#50b86c,color:#fff
| Mode | Flush Trigger | Latency | Overhead | Best For |
|---|---|---|---|---|
| Timer | Periodic timer (default 100ms) | Up to flush_interval_ms | Lowest (no per-message syscall) | High-throughput logging |
| Notify | Pipe write per message | ~Immediate | Highest (1 write() per message) | Low-latency debugging |
| Mixed | Timer + pipe for Error/Fatal | Low for errors, batched for info | Moderate | Production applications |
Log Entry Lifecycle
sequenceDiagram
participant App as Application Thread
participant Pool as Entry Freelist
participant Queue as MPSC Queue
participant L as Event Loop Thread
participant File as Log File
App->>Pool: entry_alloc()
Pool-->>App: "xLogEntry_ (recycled or malloc'd)"
App->>App: "snprintf(entry->buf, timestamp + level + message)"
App->>Queue: xMpscPush(entry)
Note over App: "Optional: write(pipe_wfd, 1) for Notify/Mixed"
L->>Queue: "xMpscPop() (timer or pipe callback)"
Queue-->>L: xLogEntry_
L->>File: "fwrite(entry->buf)"
L->>Pool: entry_free(entry)
L->>File: fflush()
Log Entry Structure
struct xLogEntry_ {
xMpsc node; // MPSC queue node
xLogLevel level; // Severity level
int len; // Formatted message length
char buf[XLOG_ENTRY_BUF_SIZE]; // Formatted message (512 bytes)
struct xLogEntry_ *free_next; // Freelist link
};
Lock-Free Entry Freelist
The freelist uses a Treiber stack with atomic CAS:
- Alloc: Pop from freelist head (CAS loop). Fallback to
malloc()if empty. - Free: Push to freelist head (CAS loop). If count exceeds
XLOG_FREELIST_SIZE, callfree()instead.
The count check is intentionally racy (soft cap) to keep the fast path lean.
File Rotation
When written >= max_size and max_files > 1:
- Delete
path.{max_files-1}(oldest) - Cascade rename:
path.{i-1}→path.{i}for i = max_files-1 down to 2 - Rename
path→path.1 - Reopen
pathin append mode
app.log → app.log.1
app.log.1 → app.log.2
app.log.2 → app.log.3
app.log.3 → (deleted if max_files=4)
Synchronous Flush
xLoggerFlush() writes a byte to a dedicated flush-request pipe, triggering logger_flush_req_cb on the event loop thread. The caller then busy-waits (polling xMpscEmpty() every 1ms, up to 1 second) until the queue is drained.
Log Format
2025-04-04 16:30:00.123 INFO Application started
2025-04-04 16:30:00.456 WARN Low memory: 1024 bytes remaining
2025-04-04 16:30:01.789 ERROR Connection refused
Format: YYYY-MM-DD HH:MM:SS.mmm LEVEL message\n
xjson — JSON Parser, Builder & Serializer
Introduction
xjson provides a complete JSON toolkit: DOM-style parsing and construction, SAX-style streaming parsing, and serialization. All built on libx's arena allocator and error-handling conventions.
Two parsing modes are supported:
| Mode | API | Use Case |
|---|---|---|
| DOM | xJsonParse / xJsonParseCopy | Full in-memory tree, query and mutate, serialize back |
| SAX | xJsonSaxParse | Large documents, callback-driven, no tree overhead |
Design Philosophy
-
Dual Memory Model — Parse trees are arena-backed with O(1)
xJsonFree(). Manually constructed trees use per-nodemallocwith recursive free. Ownership tracking viaXJSON_FLAG_OWNEDprevents double-free. -
Zero-Copy by Default —
xJsonParsestrings point into the input buffer. UsexJsonParseCopyfor safe copy into the arena when the input buffer must be freed. -
Ownership Transfer — Set/Append/Insert operations take ownership of the value node. Replacing an existing value frees the old one automatically.
-
Shared Tokenizer — The DOM and SAX parsers share a common tokenizer (
json_parse.c) that provides pure lexical helpers: skip whitespace, match literals, decode strings, and parse numbers.
Architecture
┌─────────────────────────────────────────────────────────┐
│ xjson Module │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ json.h / json.c│ │ json_sax.h / │ │
│ │ ───────────────│ │ json_sax.c │ │
│ │ DOM Parse │ │ ─────────────────│ │
│ │ DOM Construct │ │ SAX Parse │ │
│ │ Object/Array │ │ Streaming Stubs │ │
│ │ Serialize │ │ │ │
│ │ Free │ │ │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ └──────────┬───────────┘ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ json_parse.h / .c │ (internal, XCAPI_LOCAL)│
│ │ ───────────────────│ │
│ │ Tokenizer: │ │
│ │ skip_ws, match, │ │
│ │ string, number │ │
│ └─────────────────────┘ │
└─────────────────────────────────────────────────────────┘
DOM API
Parse
| Function | Description |
|---|---|
xJsonParse(json, len) | Zero-copy parse. Strings point into input buffer. |
xJsonParseCopy(json, len) | Safe-copy parse. All strings copied into arena. |
Both return NULL on parse failure. The caller must free the tree with xJsonFree().
Query
| Function | Return | UB if node is not... |
|---|---|---|
xJsonType(node) | XJSON_NULL, XJSON_BOOL, XJSON_INT, XJSON_DOUBLE, XJSON_STRING, XJSON_ARRAY, XJSON_OBJECT | — |
xJsonBool(node) | int (0 or 1) | XJSON_BOOL |
xJsonInt(node) | int64_t | XJSON_INT |
xJsonDouble(node) | double | XJSON_DOUBLE |
xJsonString(node) | const char * (NUL-terminated) | XJSON_STRING |
xJsonStringLength(node) | size_t (byte count) | XJSON_STRING |
Construct
Each constructor returns a malloc-backed node or NULL on allocation failure. Constructed nodes use the malloc memory model and are freed recursively by xJsonFree().
| Function | Creates |
|---|---|
xJsonNewNull() | null |
xJsonNewBool(v) | true or false |
xJsonNewInt(v) | Integer |
xJsonNewDouble(v) | Double |
xJsonNewString(str) | String (NUL-terminated input) |
xJsonNewStringN(str, len) | String with explicit length (supports embedded NULs) |
xJsonNewArray() | Empty array |
xJsonNewObject() | Empty object |
Object Operations
| Function | Description |
|---|---|
xJsonObjectGet(obj, key) | Look up a value by key. Returns NULL if not found. Returned node is still owned by obj. |
xJsonObjectSet(obj, key, val) | Set a key-value pair. Takes ownership of val. Replaces existing key. Returns 0 on success. |
xJsonObjectDel(obj, key) | Remove a key and free its value. No-op if key not found. |
xJsonObjectSize(obj) | Return the number of key-value pairs. |
Object Iterator
xJsonIterator *it = xJsonNewIterator(obj);
while (xJsonIteratorNext(it)) {
const char *key = xJsonIteratorKey(it, NULL);
xJson *value = xJsonIteratorValue(it);
// use key and value (value still owned by obj)
}
xJsonFree(it); // iterator must be freed independently
Modifying the object during iteration on the key returned by the iterator invalidates the iterator.
Array Operations
| Function | Description |
|---|---|
xJsonArrayGet(arr, idx) | Return the element at idx. Negative indices count from end. Returns NULL on OOB. |
xJsonArraySet(arr, idx, val) | Replace element at idx. Takes ownership of val, frees old element. Returns 0 on success. |
xJsonArrayAppend(arr, val) | Append to end. Takes ownership of val. Returns 0 on success. |
xJsonArrayInsert(arr, idx, val) | Insert at idx (0..size). Takes ownership of val. Returns 0 on success. |
xJsonArrayRemove(arr, idx) | Remove and free the element at idx. No-op on OOB. |
xJsonArraySize(arr) | Return the number of elements. |
Serialize
| Function | Output |
|---|---|
xJsonStringify(node) | Compact JSON string: {"a":1} |
xJsonStringifyPretty(node) | Pretty-printed with 2-space indent |
xJsonStringifyTo(node, pretty, buf, len) | Write to caller-supplied buffer (like snprintf). *len updated to bytes including NUL. |
xJsonStringify and xJsonStringifyPretty return malloc'd strings; the caller must free() them.
Free
xJsonFree(ptr);
Dispatch based on memory model:
- Arena-backed (parse trees): destroys the arena in O(1)
- Malloc-backed (constructed trees): recursively walks and frees the subtree
- Iterator: frees the iterator struct
- NULL: no-op
- Owned node (
XJSON_FLAG_OWNED): no-op (already transferred into a parent)
SAX API
One-Shot SAX
int xJsonSaxParse(const char *json, size_t len,
const xJsonSaxHandler *handler, void *ctx);
Parses a complete JSON document synchronously, firing callbacks as tokens are encountered:
XDEF_STRUCT(xJsonSaxHandler) {
int (*on_null)(void *ctx);
int (*on_bool)(void *ctx, int v);
int (*on_int)(void *ctx, int64_t v);
int (*on_double)(void *ctx, double v);
int (*on_string)(void *ctx, const char *s, size_t len);
int (*on_key)(void *ctx, const char *s, size_t len);
int (*on_array_begin)(void *ctx);
int (*on_array_end)(void *ctx);
int (*on_object_begin)(void *ctx);
int (*on_object_end)(void *ctx);
};
Each callback returns 0 to continue or non-zero to abort (the non-zero value is returned by xJsonSaxParse). String values are arena-backed and valid only for the duration of the callback — copy them if needed.
Returns: 0 on success, -1 on parse error, or the callback's non-zero abort value.
Streaming SAX
xJsonSax *xJsonSaxCreate(&handler, ctx, max_depth);
xJsonSaxResult xJsonSaxFeed(sax, data, len);
xJsonSaxResult xJsonSaxFinalize(sax);
void xJsonSaxReset(sax);
void xJsonSaxDestroy(sax);
Feed bytes incrementally as they arrive. Callbacks fire when complete tokens are available:
| Result | Meaning |
|---|---|
xJsonSaxResult_NeedMore (1) | Parser expects more data |
xJsonSaxResult_Done (0) | Document complete |
xJsonSaxResult_Error (-1) | Parse error |
Call xJsonSaxFinalize() after the last xJsonSaxFeed() to detect truncated documents.
Note: Streaming is currently stub-only (returns
xJsonSaxResult_Error). The full state-machine implementation is planned for a future release. UsexJsonSaxParsefor synchronous SAX parsing.
Quick Start
DOM Usage
#include <stdio.h>
#include <x/json/json.h>
int main(void) {
// Parse a JSON string
const char *json = "{\"name\":\"leo\",\"scores\":[95,87,92]}";
xJson *root = xJsonParse(json, strlen(json));
if (!root) return 1;
// Query
xJson *name = xJsonObjectGet(root, "name");
xJson *scores = xJsonObjectGet(root, "scores");
printf("Name: %s\n", xJsonString(name));
printf("Score 0: %lld\n", (long long)xJsonInt(xJsonArrayGet(scores, 0)));
// Serialize
char *out = xJsonStringifyPretty(root);
printf("%s\n", out);
free(out);
xJsonFree(root);
return 0;
}
Building from Scratch
xJson *obj = xJsonNewObject();
xJsonObjectSet(obj, "id", xJsonNewInt(1));
xJsonObjectSet(obj, "name", xJsonNewString("leo"));
xJsonObjectSet(obj, "admin", xJsonNewBool(1));
xJson *tags = xJsonNewArray();
xJsonArrayAppend(tags, xJsonNewString("c"));
xJsonArrayAppend(tags, xJsonNewString("json"));
xJsonObjectSet(obj, "tags", tags);
char *json = xJsonStringify(obj);
// {"id":1,"name":"leo","admin":true,"tags":["c","json"]}
printf("%s\n", json);
free(json);
xJsonFree(obj);
SAX Usage
#include <x/json/json_sax.h>
static int on_int(void *ctx, int64_t v) {
int64_t *sum = (int64_t *)ctx;
*sum += v;
return 0; // continue
}
int main(void) {
xJsonSaxHandler handler = {0};
handler.on_int = on_int;
int64_t sum = 0;
int r = xJsonSaxParse("[1,2,3,4,5]", 11, &handler, &sum);
if (r == 0) printf("Sum: %lld\n", (long long)sum); // Sum: 15
return r;
}
Memory Model
Parse Tree (arena-backed) Constructed Tree (malloc-backed)
──────────────────────── ────────────────────────────────
xJsonParse(str, len) xJsonNewObject()
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ xArena │ │ xJson_* │ (malloc)
│ ┌──────┐ │ │ ├─key │ (malloc)
│ │nodes │ │ │ ├─str │ (malloc)
│ │strings│ │ └─next │ ...
│ └──────┘ │ │ xJson_* │ (malloc)
└──────────┘ │ └─... │
│ │
xJsonFree(root) xJsonFree(root)
→ xArenaDestroy (O(1)) → recursive walk + free
Important: Mixing parse trees with constructed trees is unsupported and may lead to use-after-free or double-free.
Type Constants
| Constant | Value | JSON Type |
|---|---|---|
XJSON_NULL | 0x00 | null |
XJSON_BOOL | 0x01 | true / false |
XJSON_INT | 0x02 | integer |
XJSON_DOUBLE | 0x03 | floating-point |
XJSON_STRING | 0x04 | string |
XJSON_ARRAY | 0x05 | array |
XJSON_OBJECT | 0x06 | object |
Relationship with Other Modules
- xbase — Uses
xArenafor memory management in parse trees. FollowsXCAPI/XCAPI_LOCALvisibility conventions. Error handling via return values (0 = success, -1 = error). - xhttp — Can be used to parse JSON request/response bodies. No direct dependency — applications combine both modules as needed.
xhttp — Asynchronous HTTP
Introduction
xhttp is libx's HTTP module: a fully asynchronous HTTP client and server, plus WebSocket and SSE support — all powered by xbase's event loop.
- The client uses libcurl's multi-socket API for non-blocking HTTP, SSE streaming, and WebSocket connections. TLS is configured at client creation time via
xTlsConf(custom CA, mTLS, verification control). - The server uses an
xHttpProtovtable for protocol-abstracted parsing, supporting both HTTP/1.1 (llhttp) and HTTP/2 (nghttp2, h2c Prior Knowledge) on the same port. Routing is decoupled into anxHttpMuxand resolved per-request via a pluggable resolver callback. TLS listeners viaxHttpServerListenTls. - WebSocket support is symmetric:
xWsConnect()for clients,xWsUpgrade()(inside an HTTP handler) orxWsServe()(one-liner) for servers. Frame codec, ping/pong, fragment reassembly, and close negotiation are handled automatically.
Unified Type Model
The refactor collapses the previous request, response, and response-writer types into a single context type and four callback types shared by both client and server.
The xHttpCtx struct
XDEF_STRUCT(xHttpCtx) {
const char *method; /* Request method (server: from request line; client: NULL) */
const char *url; /* Request URL (server: from request line; client: NULL) */
long status_code; /* HTTP status code (e.g. 200), 0 on failure */
int curl_code; /* CURLcode (client only; 0 = CURLE_OK) */
const char *curl_error; /* Human-readable curl error, or NULL (client only) */
const char *headers; /* Raw headers (NUL-terminated) */
size_t headers_len; /* Length of headers in bytes */
void *internal_; /* Internal use (server-side response state) */
};
There is no body/body_len field. Body data is delivered as streaming chunks via on_data (download) or pulled via on_read (upload). This keeps memory usage flat regardless of transfer size.
Four symmetric callback types
| Type | Signature | Client | Server |
|---|---|---|---|
xHttpInitFunc | int (xHttpCtx *, void *) | on_response — fired once after response headers | on_request — fired once after request headers |
xHttpDataFunc | int (const char *, size_t, void *) | on_data — response body chunks | on_data — request body chunks |
xHttpReadFunc | size_t (char *, size_t, void *) | on_read — pull request body for upload | (not used) |
xHttpDoneFunc | void (xHttpCtx *, void *) | on_done — transfer complete | on_done — request fully received |
Both sides use the same xHttpInitFunc and xHttpDoneFunc types — the only difference is what fields of xHttpCtx are populated. On the client, status_code/curl_code/curl_error/headers are set. On the server, method/url/headers are set, and response state is written through internal_ via the xHttpCtx* helper functions.
Symmetric callback model
┌─────────────── Client (libcurl) ───────────────┐ ┌─────────────── Server (llhttp / nghttp2) ───────────────┐
│ │ │ │
│ xHttpClientDo(client, &conf, arg) │ │ resolver(router, ctx) → xHttpRouteInfo │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ headers received ──► on_response(ctx, arg) │ │ headers received ──► on_request(ctx, arg) │
│ │ (xHttpInitFunc) │ │ │ (xHttpInitFunc) │
│ ▼ │ │ ▼ │
│ each body chunk ──► on_data(data, len, arg) │ │ each body chunk ──► on_data(data, len, arg) │
│ │ (xHttpDataFunc) │ │ │ (xHttpDataFunc) │
│ ▼ │ │ ▼ │
│ upload pulled ◄── on_read(buf, n, arg) │ │ (no on_read on server) │
│ │ (xHttpReadFunc) │ │ │
│ ▼ │ │ ▼ │
│ transfer done ───► on_done(ctx, arg) │ │ request complete ──► on_done(ctx, arg) │
│ (xHttpDoneFunc) │ │ then write response via xHttpCtxSend / xHttpCtxWrite │
│ │ │ │
└─────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────┘
Design Philosophy
-
Event Loop Integration — xhttp registers libcurl's sockets (client) and the listening + connection sockets (server) with
xEventLoop. All callbacks run on the event loop thread — no locks. -
Streaming First — Bodies flow through callbacks as chunks, never buffered in full by the library. Uploads are pulled via
on_read, downloads pushed viaon_data. Collect-into-buffer helpers are a few lines of user code (see client.md). -
Decoupled Routing (server) —
xHttpServerCreate(conf)takes a resolver callback. The built-inxHttpMux+xHttpMuxResolvecovers the common case; custom resolvers can dispatch on any criterion (method, host, header). -
Symmetric Callback Types — Client and server share
xHttpInitFunc,xHttpDataFunc,xHttpDoneFunc. OnlyxHttpReadFuncis client-only (upload side). -
Automatic Resource Management — Request contexts, curl easy handles, and buffers are cleaned up after
on_donereturns. In-flight requests are cancelled with error callbacks when the client is destroyed.
Architecture
graph TD
subgraph "Application"
APP["User Code"]
end
subgraph "xhttp Client"
CLIENT["xHttpClient"]
TLS_CLI["TLS Config<br/>(xTlsConf)"]
ONESHOT["Oneshot Request<br/>(Do / Get / Post)"]
SSE["SSE Request<br/>(GetSse / DoSse)"]
WS_C["WebSocket Client<br/>(xWsConnect)"]
PARSER["SSE Parser<br/>(W3C spec)"]
end
subgraph "xhttp Server"
SERVER["xHttpServer"]
RESOLVER["Resolver<br/>(xHttpMuxResolve or custom)"]
MUX["xHttpMux<br/>(pattern router)"]
PROTO["xHttpProto vtable<br/>(H1 / H2)"]
WS_S["WebSocket Server<br/>(xWsUpgrade / xWsServe)"]
end
subgraph "xbase"
LOOP["xEventLoop"]
TIMER["Timers"]
FD["FD Events"]
end
APP -->|"xHttpClientDo/Get/Post"| ONESHOT
APP -->|"xHttpClientGetSse/DoSse"| SSE
APP -->|"xWsConnect"| WS_C
APP -->|"xHttpServerCreate + Listen"| SERVER
APP -->|"xHttpMuxHandle"| MUX
APP -->|"xWsUpgrade / xWsServe"| WS_S
APP -->|"xHttpClientConf.tls"| TLS_CLI
ONESHOT --> CLIENT
SSE --> PARSER --> CLIENT
WS_C --> CLIENT
TLS_CLI --> CLIENT
SERVER --> RESOLVER
RESOLVER --> MUX
SERVER --> PROTO
MUX --> WS_S
CLIENT --> LOOP
SERVER --> LOOP
TIMER --> LOOP
FD --> LOOP
style CLIENT fill:#4a90d9,color:#fff
style SERVER fill:#4a90d9,color:#fff
style LOOP fill:#50b86c,color:#fff
style PROTO fill:#9b59b6,color:#fff
Sub-Module Overview
| File | Description | Doc |
|---|---|---|
client.h | Async HTTP client (xHttpCtx, xHttpRequestConf, GET/POST/Do, SSE) | client.md |
server.h | Async HTTP/1.1 & HTTP/2 server (resolver, xHttpMux, xHttpCtx write API) | server.md |
sse.c | SSE stream parser and request handler | sse.md |
ws.h (server) | WebSocket server API (xWsUpgrade, xWsServe, send, close) | ws_server.md |
ws.h (client) | WebSocket client API (xWsConnect, send, close) | ws_client.md |
| (guide) | TLS deployment guide (cert generation, one-way TLS, mTLS) | tls.md |
Quick Start
Client (GET request)
#include <stdio.h>
#include <string.h>
#include <x/base/event.h>
#include <x/http/client.h>
/* A tiny accumulator — body chunks arrive via on_data. */
struct Resp { long status; char *buf; size_t len; };
static int on_data(const char *data, size_t len, void *arg) {
struct Resp *r = arg;
r->buf = realloc(r->buf, r->len + len + 1);
memcpy(r->buf + r->len, data, len);
r->len += len;
r->buf[r->len] = '\0';
return 0;
}
static void on_done(xHttpCtx *ctx, void *arg) {
struct Resp *r = arg;
r->status = ctx->status_code;
if (ctx->curl_code != 0)
printf("Error: %s\n", ctx->curl_error ? ctx->curl_error : "?");
else
printf("HTTP %ld: %s\n", r->status, r->buf ? r->buf : "(empty)");
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpClient client = xHttpClientCreate(NULL);
xHttpRequestConf conf = {0};
conf.url = "https://httpbin.org/get";
conf.on_data = on_data;
conf.on_done = on_done;
struct Resp r = {0};
xHttpClientGet(client, &conf, &r);
xEventLoopRun(loop);
free(r.buf);
xHttpClientDestroy(client);
xEventLoopDestroy(loop);
return 0;
}
Server (router + handler)
#include <stdio.h>
#include <x/base/event.h>
#include <x/http/server.h>
static void on_hello(xHttpCtx *ctx, void *arg) {
(void)arg;
xHttpCtxSetHeader(ctx, "Content-Type", "text/plain");
xHttpCtxSend(ctx, "Hello, World!\n", 14);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpMux mux = xHttpMuxCreate();
xHttpRouteConf route = {
.pattern = "GET /hello",
.on_request = on_hello,
};
xHttpMuxHandle(mux, &route);
xHttpServerConf sconf = {0};
sconf.resolve = xHttpMuxResolve;
sconf.router = mux;
xHttpServer server = xHttpServerCreate(&sconf);
xHttpServerListen(server, "0.0.0.0", 8080);
printf("Listening on :8080\n");
xEventLoopRun(loop);
xHttpServerDestroy(server);
xHttpMuxDestroy(mux);
xEventLoopDestroy(loop);
return 0;
}
Relationship with Other Modules
- xbase — Uses
xEventLoopfor I/O multiplexing andxEventLoopTimerAfterfor curl timeout management and idle-connection timeouts. - xbuf — Uses
xBufferfor header accumulation andxIOBufferfor connection read/write buffering. - xnet — Provides
xTlsConf,xTlsCtx, and DNS resolution used by client, server, and WebSocket code. - libcurl — External dependency (client). Multi-socket API (
curl_multi_socket_action) for non-blocking HTTP and SSE. - llhttp — External dependency (server). Incremental HTTP/1.1 parsing, isolated behind the
xHttpProtovtable inproto_h1.c. - nghttp2 — External dependency (server). HTTP/2 frame processing and HPACK, isolated behind the
xHttpProtovtable inproto_h2.c.
client.h — Asynchronous HTTP Client
Introduction
client.h provides xHttpClient, an asynchronous HTTP client that integrates libcurl's multi-socket API with xbase's event loop. All network I/O is non-blocking and driven by the event loop; completion callbacks are dispatched on the event loop thread. The client supports GET, POST, PUT, DELETE, PATCH, HEAD methods, streaming upload/download, and Server-Sent Events (SSE).
Design Philosophy
-
libcurl Multi-Socket Integration — xhttp uses
CURLMOPT_SOCKETFUNCTION+CURLMOPT_TIMERFUNCTIONso libcurl delegates socket monitoring toxEventLoop. No dedicated threads, no polling. -
Single-Threaded Callback Model — All callbacks (
on_response,on_data,on_read,on_done) run on the event loop thread. No locks needed in callback code. -
Streaming Bodies — There is no
body/body_lenfield onxHttpCtx. Response body chunks arrive viaon_data; request body bytes are pulled viaon_read. Memory use is flat regardless of transfer size. -
One Config Struct, Four Optional Callbacks —
xHttpRequestConfcarries the URL, method, headers, and the four callbacks. Any callback leftNULLis skipped — seton_doneonly for fire-and-forget with completion notification, or set all four for full streaming. -
Vtable-Based Polymorphism — Internally, each request carries a vtable (
xHttpReqVtable) withon_doneandon_cleanupfunction pointers. Oneshot, SSE, and WebSocket requests share the same curl multi handle and event-loop infrastructure.
Architecture
graph TD
subgraph xHttpClientInternal[xHttpClient Internal]
MULTI[curl multi handle]
TIMER_CB[timer callback - CURLMOPT TIMERFUNCTION]
SOCKET_CB[socket callback - CURLMOPT SOCKETFUNCTION]
CHECK[check multi info]
end
subgraph PerRequest[Per Request]
REQ[xHttpReq]
EASY[curl easy handle]
HDR[xBuffer headers]
VT[vtable - oneshot or SSE]
end
subgraph xbaseEventLoop[xbase Event Loop]
LOOP[xEventLoop]
FD_EVT[FD events]
TIMER_EVT[Timer events]
end
SOCKET_CB --> FD_EVT
TIMER_CB --> TIMER_EVT
FD_EVT --> LOOP
TIMER_EVT --> LOOP
LOOP -->|fd ready| CHECK
LOOP -->|timeout| CHECK
CHECK --> VT
VT -->|on_response / on_data / on_done| APP[User Callbacks]
REQ --> EASY
REQ --> HDR
REQ --> VT
style MULTI fill:#f5a623,color:#fff
style LOOP fill:#50b86c,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xHttpClient | Opaque handle to an HTTP client bound to an event loop |
xHttpCtx | Per-request context (status, headers, curl error) — no body field |
xHttpInitFunc | int (*)(xHttpCtx *ctx, void *arg) — on_response, fired once after headers |
xHttpDataFunc | int (*)(const char *data, size_t len, void *arg) — on_data, per body chunk |
xHttpReadFunc | size_t (*)(char *buf, size_t bufsize, void *arg) — on_read, upload pull |
xHttpDoneFunc | void (*)(xHttpCtx *ctx, void *arg) — on_done, completion |
xHttpMethod | Enum: GET, POST, PUT, DELETE, PATCH, HEAD |
xHttpVersion | Enum: Default, H1, H2, H2TLS, H2C |
xHttpRequestConf | Per-request configuration (URL, method, headers, callbacks) |
xHttpClientConf | Client creation config (TLS, default HTTP version) |
xSseEvent | SSE event delivered to xSseEventFunc |
xSseEventFunc | int (*)(const xSseEvent *ev, void *arg) — return 0 to continue, non-zero to close |
xSseDoneFunc | void (*)(int curl_code, void *arg) — SSE stream end |
xTlsConf | TLS configuration (CA, client cert/key, skip verify) |
xHttpCtx
XDEF_STRUCT(xHttpCtx) {
const char *method; /* Client: NULL */
const char *url; /* Client: NULL */
long status_code; /* HTTP status, 0 on failure*/
int curl_code; /* CURLcode, 0 = success */
const char *curl_error; /* Human-readable, or NULL */
const char *headers; /* Raw response headers */
size_t headers_len;
void *internal_; /* Internal (client: NULL) */
};
All pointers are valid only for the duration of the callback. The library manages their lifetime. There is no body field — body data is delivered via on_data (or discarded if on_data is NULL).
xHttpRequestConf
XDEF_STRUCT(xHttpRequestConf) {
const char *url; /* Required */
xHttpMethod method; /* Default GET */
size_t content_length; /* Body size for on_read (0=chunked) */
const char **headers; /* NULL-terminated "Key: Value" */
long timeout_ms; /* 0 = no limit */
xHttpVersion http_version; /* 0 = client default */
xHttpInitFunc on_response; /* Once after response headers */
xHttpDataFunc on_data; /* Per body chunk (NULL = discard)*/
xHttpReadFunc on_read; /* Upload provider (NULL = no body) */
xHttpDoneFunc on_done; /* Completion (NULL = fire-and-forget) */
};
Zero-initialize for defaults: GET, no headers, no body, no callbacks, no timeout.
| Field | Notes |
|---|---|
content_length | When on_read is set: known body size for Content-Length, or 0 for Transfer-Encoding: chunked. |
timeout_ms | For regular HTTP: total transfer timeout. For SSE: connection-phase timeout only; stalled streams are detected via libcurl's low-speed-time. |
on_response | Returns non-zero to abort before any body data is delivered. |
on_data | Returns non-zero to abort the transfer. |
on_read | Returns bytes written into buf; 0 signals EOF. |
xHttpClientConf
XDEF_STRUCT(xHttpClientConf) {
const xTlsConf *tls; /* NULL = no TLS config */
xHttpVersion http_version; /* 0 = H1 (default) */
};
Pass NULL to xHttpClientCreate() for the same defaults.
Lifecycle
| Function | Signature | Description |
|---|---|---|
xHttpClientCreate | xHttpClient xHttpClientCreate(const xHttpClientConf *conf) | Create a client. conf may be NULL for defaults. |
xHttpClientDestroy | void xHttpClientDestroy(xHttpClient client) | Destroy client. In-flight requests are cancelled; their on_done is invoked with an error status. |
Request Submission
All three take (client, conf, arg) — there is no separate on_response parameter. Get/Post force the method and delegate to Do.
| Function | Signature | Description |
|---|---|---|
xHttpClientGet | xErrno xHttpClientGet(xHttpClient client, const xHttpRequestConf *conf, void *arg) | Force conf->method = GET, delegate to Do. |
xHttpClientPost | xErrno xHttpClientPost(xHttpClient client, const xHttpRequestConf *conf, void *arg) | Force conf->method = POST, delegate to Do. Body via conf->on_read. |
xHttpClientDo | xErrno xHttpClientDo(xHttpClient client, const xHttpRequestConf *conf, void *arg) | Fully-configured async request. All callbacks come from conf. |
arg is forwarded unchanged to every callback (on_response, on_data, on_read, on_done).
SSE Requests
| Function | Signature | Description |
|---|---|---|
xHttpClientGetSse | xErrno xHttpClientGetSse(xHttpClient client, const char *url, xSseEventFunc on_event, xSseDoneFunc on_done, void *arg) | Simple GET SSE subscription. |
xHttpClientDoSse | xErrno xHttpClientDoSse(xHttpClient client, const xHttpRequestConf *config, xSseEventFunc on_event, xSseDoneFunc on_done, void *arg) | Fully-configured SSE request — POST + JSON body for LLM APIs. Accept: text/event-stream is added automatically. |
See sse.md for SSE details.
HTTP Version Configuration
The xHttpClientConf.http_version field sets the default for all requests; xHttpRequestConf.http_version overrides per-request (0 = use client default).
| Value | Description |
|---|---|
xHttpVersion_Default | Use client default (initially HTTP/1.1) |
xHttpVersion_H1 | Force HTTP/1.1 |
xHttpVersion_H2 | HTTP/2 with TLS (ALPN), fallback to H1 |
xHttpVersion_H2TLS | HTTP/2 over TLS only, no fallback |
xHttpVersion_H2C | HTTP/2 cleartext (Prior Knowledge) |
TLS Configuration
TLS is configured at client creation time via xHttpClientConf.tls. The xTlsConf fields are deep-copied internally.
xTlsConf Field | Description |
|---|---|
ca | Path to a CA cert file. When set, system CA bundle is bypassed. |
cert | Path to a client cert (PEM) for mTLS. |
key | Path to the client private key (PEM). |
key_password | Passphrase for an encrypted private key. |
skip_verify | Non-zero to skip server cert verification (dev only). |
To change TLS config, destroy and recreate the client.
Usage Examples
Simple GET (collect body via on_data)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <x/base/event.h>
#include <x/http/client.h>
struct Resp {
long status;
int curl_code;
char *buf;
size_t len;
};
static int on_data(const char *data, size_t len, void *arg) {
struct Resp *r = arg;
r->buf = realloc(r->buf, r->len + len + 1);
if (!r->buf) return 1; /* abort on OOM */
memcpy(r->buf + r->len, data, len);
r->len += len;
r->buf[r->len] = '\0';
return 0;
}
static void on_done(xHttpCtx *ctx, void *arg) {
struct Resp *r = arg;
r->status = ctx->status_code;
r->curl_code = ctx->curl_code;
if (ctx->curl_code != 0)
printf("Error: %s\n", ctx->curl_error ? ctx->curl_error : "?");
else
printf("HTTP %ld, %zu bytes\n", r->status, r->len);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpClient client = xHttpClientCreate(NULL);
xHttpRequestConf conf = {0};
conf.url = "https://httpbin.org/get";
conf.on_data = on_data;
conf.on_done = on_done;
struct Resp r = {0};
xHttpClientGet(client, &conf, &r);
xEventLoopRun(loop);
free(r.buf);
xHttpClientDestroy(client);
xEventLoopDestroy(loop);
return 0;
}
HTTPS with TLS configuration
xTlsConf tls = {0};
tls.skip_verify = 1; /* dev only */
xHttpClientConf conf = {.tls = &tls};
xHttpClient client = xHttpClientCreate(&conf);
xHttpRequestConf req = {0};
req.url = "https://secure.example.com/api";
req.on_done = on_done;
xHttpClientGet(client, &req, NULL);
POST with a static body (on_read)
#include <string.h>
#include <x/base/event.h>
#include <x/http/client.h>
struct Upload {
const char *data;
size_t len;
size_t off;
};
static size_t on_read(char *buf, size_t bufsize, void *arg) {
struct Upload *u = arg;
size_t remaining = u->len - u->off;
if (remaining == 0) return 0; /* EOF */
size_t n = bufsize < remaining ? bufsize : remaining;
memcpy(buf, u->data + u->off, n);
u->off += n;
return n;
}
static void on_done(xHttpCtx *ctx, void *arg) {
(void)arg;
if (ctx->curl_code == 0)
printf("POST → HTTP %ld\n", ctx->status_code);
else
printf("Error: %s\n", ctx->curl_error);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpClient client = xHttpClientCreate(NULL);
const char *body = "{\"key\": \"value\"}";
struct Upload up = { body, strlen(body), 0 };
const char *headers[] = {
"Content-Type: application/json",
"Authorization: Bearer token123",
NULL,
};
xHttpRequestConf conf = {0};
conf.url = "https://api.example.com/data";
conf.method = xHttpMethod_POST;
conf.content_length = up.len;
conf.headers = headers;
conf.on_read = on_read;
conf.on_done = on_done;
xHttpClientPost(client, &conf, &up);
xEventLoopRun(loop);
xHttpClientDestroy(client);
xEventLoopDestroy(loop);
return 0;
}
POST with chunked streaming upload
Omit content_length (leave it 0) when the total size is unknown or when streaming generated data. libcurl sends Transfer-Encoding: chunked automatically.
static size_t on_read_chunked(char *buf, size_t bufsize, void *arg) {
/* Generate or pull next chunk. Return 0 to signal EOF. */
size_t n = produce_next_chunk(buf, bufsize, arg);
return n;
}
xHttpRequestConf conf = {0};
conf.url = "https://api.example.com/upload";
conf.method = xHttpMethod_POST;
conf.content_length = 0; /* chunked */
conf.on_read = on_read_chunked;
conf.on_done = on_done;
Streaming download (write to file as chunks arrive)
static int on_data_to_file(const char *data, size_t len, void *arg) {
FILE *f = arg;
fwrite(data, 1, len, f);
return 0;
}
xHttpRequestConf conf = {0};
conf.url = "https://example.com/large-file.bin";
conf.on_data = on_data_to_file;
conf.on_done = on_done;
xHttpClientGet(client, &conf, fopen("out.bin", "wb"));
Inspect headers before deciding to download
on_response fires once after headers are parsed. Return non-zero to abort before any body data is transferred.
static int on_response(xHttpCtx *ctx, void *arg) {
if (ctx->status_code != 200) {
printf("Not OK: %ld, aborting\n", ctx->status_code);
return 1; /* abort — on_done fires with error */
}
return 0; /* continue to on_data */
}
xHttpRequestConf conf = {0};
conf.url = "https://example.com/resource";
conf.on_response = on_response;
conf.on_data = on_data;
conf.on_done = on_done;
Body Collection Pattern
The library deliberately does not provide a built-in "collect body into a buffer" helper — it is a few lines of user code, and inlining it lets you choose your own allocator and growth strategy. The pattern is always the same:
struct Body { char *data; size_t len, cap; };
static int body_collect(const char *data, size_t len, void *arg) {
struct Body *b = arg;
if (b->len + len + 1 > b->cap) {
size_t ncap = b->cap ? b->cap * 2 : 4096;
while (ncap < b->len + len + 1) ncap *= 2;
char *p = realloc(b->data, ncap);
if (!p) return 1; /* abort on OOM */
b->data = p;
b->cap = ncap;
}
memcpy(b->data + b->len, data, len);
b->len += len;
b->data[b->len] = '\0'; /* NUL-terminate for convenience */
return 0;
}
Pass body_collect as conf.on_data and read b->data / b->len from on_done. The same pattern works for on_read upload in reverse — keep an offset and copy out of your source buffer.
Use Cases
- REST API Integration — Async calls to microservices, cloud APIs, or webhooks from an event-driven C application.
- Streaming Uploads / Downloads — Large file transfers without buffering the whole payload in memory. Use
on_readto pull from a file or generator; useon_datato write chunks to disk as they arrive. - LLM API Calls —
xHttpClientDoSse()with POST + JSON body to stream from OpenAI, Anthropic, or any OpenAI-compatible API. See sse.md. - Conditional Fetches — Inspect status and headers in
on_response, abort early on 3xx/4xx before any body data is transferred. - Health Checks / Monitoring — Periodically poll endpoints from a timer callback on the same event loop.
Best Practices
- Don't block in callbacks. All callbacks run on the event loop thread. Blocking delays every other I/O on the loop.
- Copy data you need to keep. Pointers in
xHttpCtx(headers,curl_error) and thedatapointer inon_dataare valid only during the callback. - Use
xHttpClientDo()for full control.Get/Postare thin wrappers that force the method —Doaccepts whatever is inconf. - Destroy the client before the event loop.
xHttpClientDestroy()cancels in-flight requests and invokes theiron_donewith an error status before resources are freed. - Check
curl_codefirst. Acurl_codeof 0 means the HTTP transfer succeeded; then checkstatus_codefor the HTTP-level result. Non-zerocurl_codeindicates a transport/DNS/TLS failure. - Never use
skip_verifyin production. It disables all certificate validation. Use a proper CA path or system CA bundle instead. - For SSE,
timeout_msonly covers the connection phase. Once the stream is established, stalled streams are detected via libcurl's low-speed-time mechanism, preventing premature disconnection during slow LLM token generation.
Comparison with Other Libraries
| Feature | xhttp client.h | libcurl easy API | cpp-httplib | Python requests |
|---|---|---|---|---|
| I/O Model | Async (event loop) | Blocking | Blocking | Blocking |
| Event Loop | xEventLoop integration | None (or manual multi) | None | None (asyncio separate) |
| Streaming Upload | on_read callback | READFUNCTION | No | No (stream=...) |
| Streaming Download | on_data callback | WRITEFUNCTION | No | iter_content |
| SSE Support | Built-in (GetSse/DoSse) | Manual parsing | No | No (needs sseclient) |
| TLS Config | xHttpClientConf.tls at creation | curl_easy_setopt (manual) | Built-in | verify/cert params |
| Thread Model | Single-threaded callbacks | One thread per request | One thread per request | One thread per request |
| Language | C99 | C | C++ | Python |
Key Differentiator: xhttp provides true event-loop-integrated async HTTP with streaming upload and download, plus a built-in SSE parser — all from a single-threaded callback model. The multi-socket API integration means zero-overhead I/O multiplexing alongside other event-loop sources (timers, signals, custom FDs).
Implementation Details
libcurl + xEventLoop Integration
sequenceDiagram
participant App as Application
participant Client as xHttpClient
participant Curl as CurlMulti
participant L as xEventLoop
App->>Client: xHttpClientDo(client, &conf, arg)
Client->>Curl: curl_multi_add_handle(easy)
Curl->>Client: socket callback fd POLL_IN
Client->>L: xEventAdd(fd, READ)
L->>Client: fd ready
Client->>Curl: curl_multi_socket_action(fd)
Curl->>Client: header callback / write callback
Client->>App: on_response(ctx) — headers complete
Client->>App: on_data(chunk) — per body chunk
Note over Curl: Transfer complete
Client->>Curl: curl_multi_info_read()
Client->>App: on_done(ctx)
Socket Callback Flow
When libcurl needs to monitor a socket, it calls socket_callback:
CURL_POLL_REMOVE— Unregister the fd from the event loop (xEventDel).CURL_POLL_IN/OUT/INOUT— Register or update the fd with the event loop (xEventAdd/xEventMod).
Each socket gets an xHttpSocketCtx_ mapping the fd back to the client and event source.
Timer Callback Flow
When libcurl needs a timeout:
timeout_ms == -1— Cancel any existing timer.timeout_ms == 0— Schedule a 1ms timer (deferred to avoid reentrantcurl_multi_socket_action).timeout_ms > 0— Schedule viaxEventLoopTimerAfter.
When the timer fires, curl_multi_socket_action(CURL_SOCKET_TIMEOUT) is called.
Request Lifecycle
stateDiagram-v2
[*] --> Submitted: xHttpClientDo/Get/Post
Submitted --> InFlight: curl_multi_add_handle
InFlight --> HeadersReceived: all headers parsed
HeadersReceived --> Streaming: on_response fires
Streaming --> Done: curl reports CURLMSG_DONE
Done --> CallbackInvoked: on_done(ctx)
CallbackInvoked --> CleanedUp: free buffers + easy handle
CleanedUp --> [*]
InFlight --> Aborted: xHttpClientDestroy
Aborted --> CallbackInvoked: on_done(error ctx)
server.h — Asynchronous HTTP/1.1 & HTTP/2 Server
Introduction
server.h provides xHttpServer, an asynchronous, non-blocking HTTP server powered by xbase's event loop. The server supports both HTTP/1.1 (llhttp) and HTTP/2 (nghttp2, h2c Prior Knowledge) on the same port with automatic protocol detection. TLS/HTTPS listeners are supported via xHttpServerListenTls() with pluggable TLS backends (OpenSSL or Mbed TLS). All connection handling, request parsing, and response writing happen on a single thread — no locks or thread pools.
Routing is decoupled from the server: xHttpServerCreate(conf) takes a resolver callback that maps each incoming request to a xHttpRouteInfo (a struct of on_request / on_data / on_done callbacks). The built-in xHttpMux provides pattern-based routing; custom resolvers can dispatch on any criterion.
Design Philosophy
-
Single-Threaded Event-Driven I/O — Accept, read, parse, dispatch, and write all happen on the event loop thread, eliminating synchronization overhead.
-
Protocol-Abstracted Parsing — Request parsing is delegated to a protocol handler behind the
xHttpProtovtable. HTTP/1.1 (proto_h1.c) uses llhttp; HTTP/2 (proto_h2.c) uses nghttp2. Both share the same connection management, routing, and response-writing layers. -
Decoupled Resolver —
xHttpServerConf.resolveis a function pointer. The server does not own route state — your resolver returns axHttpRouteInfo *(which may come from axHttpMux, a static table, or computed on the fly). This makes routing trivially extensible. -
Streaming Request Body — Request body chunks arrive via
on_data; the request is complete whenon_donefires. There is nobody/body_lenfield onxHttpCtxand nomax_body_sizelimit — the application decides how much to buffer. -
Response via
xHttpCtx*functions —xHttpCtxSetStatus,xHttpCtxSetHeader,xHttpCtxSend(one-shot),xHttpCtxWrite(streaming),xHttpCtxYield/xHttpCtxResume(async response),xHttpCtxParam(path parameters). No separate response writer handle. -
Defensive Limits — Configurable limits on header size (default 8 KiB) and idle timeout (default 60 s) protect against slow clients. Violations produce appropriate 4xx error responses.
-
Pluggable TLS — TLS via
xHttpServerListenTls()withxTlsConf. ALPN negotiation selects HTTP/1.1 or HTTP/2 over TLS. mTLS is supported whencais set (verification enabled by default).
Architecture
graph TD
subgraph "Application"
APP["User Code"]
HANDLER["on_request / on_data / on_done"]
end
subgraph "xhttp Server"
SERVER["xHttpServer"]
TLS["TLS Layer<br/>(OpenSSL / Mbed TLS)"]
RESOLVER["Resolver<br/>(xHttpMuxResolve or custom)"]
MUX["xHttpMux<br/>(pattern table)"]
CONN["xHttpConn_<br/>(per connection)"]
DETECT["Protocol Detection<br/>(Prior Knowledge / ALPN)"]
PROTO["xHttpProto (vtable)"]
PARSER_H1["proto_h1 (llhttp)"]
PARSER_H2["proto_h2 (nghttp2)"]
STREAM["xHttpStream_<br/>(per request)"]
end
subgraph "xbase"
LOOP["xEventLoop"]
SOCK["xSocket"]
TIMER["Idle Timeout"]
end
APP -->|"xHttpMuxHandle"| MUX
APP -->|"xHttpServerCreate + Listen"| SERVER
SERVER -->|"accept()"| CONN
SERVER -.->|"TLS handshake"| TLS
TLS -.-> CONN
CONN --> DETECT
DETECT -->|"H1"| PARSER_H1
DETECT -->|"H2 preface"| PARSER_H2
PARSER_H1 --> PROTO
PARSER_H2 --> PROTO
PROTO -->|"headers complete"| STREAM
STREAM --> RESOLVER
RESOLVER --> MUX
MUX -->|"first match"| HANDLER
HANDLER -->|"xHttpCtxSend / xHttpCtxWrite"| STREAM
STREAM -->|"H1: xIOBuffer / H2: nghttp2 frames"| CONN
CONN --> SOCK
SOCK --> LOOP
TIMER --> LOOP
style SERVER fill:#4a90d9,color:#fff
style LOOP fill:#50b86c,color:#fff
style PROTO fill:#9b59b6,color:#fff
style PARSER_H1 fill:#f5a623,color:#fff
style PARSER_H2 fill:#e74c3c,color:#fff
style DETECT fill:#1abc9c,color:#fff
style TLS fill:#2ecc71,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xHttpServer | Opaque handle to an HTTP server bound to an event loop |
xHttpMux | Opaque handle to the built-in pattern router |
xHttpCtx | Per-request context (method, url, headers, internal response state) |
xHttpInitFunc | int (*)(xHttpCtx *ctx, void *arg) — on_request, fired after headers |
xHttpDataFunc | int (*)(const char *data, size_t len, void *arg) — on_data, request body chunks |
xHttpDoneFunc | void (*)(xHttpCtx *ctx, void *arg) — on_done, request complete |
xHttpResolveFunc | const xHttpRouteInfo *(*)(void *router, xHttpCtx *ctx) — maps a request to a route |
xHttpRouteInfo | Struct returned by the resolver: on_request / on_data / on_done / arg |
xHttpServerConf | Server creation config: resolve, router, idle_timeout_ms, max_header_size |
xHttpRouteConf | Route registration for xHttpMux: pattern + the three callbacks + arg |
xTlsConf | TLS configuration for HTTPS listeners |
xHttpServerConf
XDEF_STRUCT(xHttpServerConf) {
xHttpResolveFunc resolve; /* NULL → all requests get 404 */
void *router; /* Opaque, passed to resolve */
int idle_timeout_ms; /* 0 = default (60000 ms) */
size_t max_header_size; /* 0 = default (8192 bytes) */
};
Zero-initialize for defaults. If resolve is NULL, every request gets a 404.
xHttpRouteConf
XDEF_STRUCT(xHttpRouteConf) {
const char *pattern; /* "METHOD /path" or "/path" (any method) */
xHttpInitFunc on_request; /* After headers (may be NULL) */
xHttpDataFunc on_data; /* Per body chunk (may be NULL) */
xHttpDoneFunc on_done; /* At request completion (may be NULL) */
void *arg; /* Forwarded to all callbacks */
};
xHttpRouteInfo
Returned by the resolver. The library does not copy this struct — the pointer must remain valid for the duration of the request (typically it lives inside the xHttpMux's route table or a static array).
XDEF_STRUCT(xHttpRouteInfo) {
xHttpInitFunc on_request;
xHttpDataFunc on_data;
xHttpDoneFunc on_done;
void *arg;
};
Lifecycle
| Function | Signature | Description |
|---|---|---|
xHttpServerCreate | xHttpServer xHttpServerCreate(const xHttpServerConf *conf) | Create a server. conf may be NULL for defaults (no resolver → 404). |
xHttpServerListen | xErrno xHttpServerListen(xHttpServer server, const char *host, uint16_t port) | Start listening for HTTP (cleartext). |
xHttpServerListenTls | xErrno xHttpServerListenTls(xHttpServer server, const char *host, uint16_t port, const xTlsConf *config) | Start listening for HTTPS. ALPN selects H1/H2. Returns xErrno_NotSupported if no TLS backend was compiled. Can coexist with Listen on a different port. |
xHttpServerDestroy | void xHttpServerDestroy(xHttpServer server) | Destroy server, close all connections. Safe to call with NULL. |
Configuration
| Function | Description | Default |
|---|---|---|
xHttpServerSetMaxHeaderSize(server, max_size) | Set max header size. Exceeding → 431. Must be called before Listen / ListenTls. | 8192 bytes |
Idle timeout and max header size can also be set via xHttpServerConf at creation time.
Mux (built-in router)
| Function | Signature | Description |
|---|---|---|
xHttpMuxCreate | xHttpMux xHttpMuxCreate(void) | Create a new multiplexer. |
xHttpMuxDestroy | void xHttpMuxDestroy(xHttpMux mux) | Destroy the mux and free all registered routes. |
xHttpMuxHandle | xErrno xHttpMuxHandle(xHttpMux mux, const xHttpRouteConf *conf) | Register a route. |
xHttpMuxResolve | const xHttpRouteInfo *xHttpMuxResolve(void *router, xHttpCtx *ctx) | Resolver function — pass as xHttpServerConf.resolve with the mux as router. |
pattern follows Go's http.HandleFunc convention:
"GET /users/:id"— matches only GET to/users/:id"/users/:id"— matches all methods to/users/:id
Routes are matched in registration order (first match wins). Path segments support :param capture — read with xHttpCtxParam(ctx, "id", &len).
Response writing — xHttpCtx* functions
| Function | Signature | Description |
|---|---|---|
xHttpCtxSetStatus | void xHttpCtxSetStatus(xHttpCtx *ctx, int code) | Set HTTP status (default 200). |
xHttpCtxSetHeader | xErrno xHttpCtxSetHeader(xHttpCtx *ctx, const char *key, const char *value) | Add a response header. Call before Send or the first Write. |
xHttpCtxSend | xErrno xHttpCtxSend(xHttpCtx *ctx, const char *body, size_t body_len) | Send a complete response (status + headers + body). Mutually exclusive with Write. May only be called once. |
xHttpCtxWrite | xErrno xHttpCtxWrite(xHttpCtx *ctx, const char *data, size_t len) | Write streaming response data (no Content-Length). First call flushes status + headers. Mutually exclusive with Send. Stream is auto-ended when on_done returns. |
xHttpCtxYield | void xHttpCtxYield(xHttpCtx *ctx) | Prevent the auto-200 sent when on_done returns without writing. Use when the response will be sent later from another callback. |
xHttpCtxResume | void xHttpCtxResume(xHttpCtx *ctx) | Resume a yielded connection after sending the response. |
xHttpCtxParam | const char *xHttpCtxParam(xHttpCtx *ctx, const char *name, size_t *len) | Look up a path parameter by name. Returns a pointer (NOT NUL-terminated) and sets *len, or NULL if not found. |
TLS Configuration
xTlsConf Field | Description |
|---|---|
cert | Path to PEM certificate file (required for ListenTls). |
key | Path to PEM private key file (required). |
ca | Path to CA cert file for client verification (optional — enables mTLS). |
skip_verify | Non-zero to skip peer verification. Default 0 (verify enabled). |
When ca is set and skip_verify is 0, the server performs mTLS — clients must present a valid certificate signed by the specified CA.
Usage Examples
Minimal server
#include <stdio.h>
#include <x/base/event.h>
#include <x/http/server.h>
static int on_hello(xHttpCtx *ctx, void *arg) {
(void)arg;
xHttpCtxSetHeader(ctx, "Content-Type", "text/plain");
xHttpCtxSend(ctx, "Hello, World!\n", 14);
return 0;
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpMux mux = xHttpMuxCreate();
xHttpRouteConf route = {
.pattern = "GET /hello",
.on_request = on_hello,
};
xHttpMuxHandle(mux, &route);
xHttpServerConf sconf = {0};
sconf.resolve = xHttpMuxResolve;
sconf.router = mux;
xHttpServer server = xHttpServerCreate(&sconf);
xHttpServerListen(server, "0.0.0.0", 8080);
printf("Listening on :8080\n");
xEventLoopRun(loop);
xHttpServerDestroy(server);
xHttpMuxDestroy(mux);
xEventLoopDestroy(loop);
return 0;
}
Echo server with streaming upload
on_data collects the request body; on_done sends the response once the body is fully received.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <x/base/event.h>
#include <x/http/server.h>
struct Echo {
char *buf;
size_t len, cap;
};
static int on_data(const char *data, size_t len, void *arg) {
struct Echo *e = arg;
if (e->len + len > e->cap) {
size_t ncap = e->cap ? e->cap * 2 : 1024;
while (ncap < e->len + len) ncap *= 2;
char *p = realloc(e->buf, ncap);
if (!p) return 1;
e->buf = p;
e->cap = ncap;
}
memcpy(e->buf + e->len, data, len);
e->len += len;
return 0;
}
static void on_done(xHttpCtx *ctx, void *arg) {
struct Echo *e = arg;
xHttpCtxSetStatus(ctx, 200);
xHttpCtxSetHeader(ctx, "Content-Type", "application/octet-stream");
xHttpCtxSend(ctx, e->buf ? e->buf : "", e->len);
free(e->buf);
free(e);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpMux mux = xHttpMuxCreate();
xHttpRouteConf route = {
.pattern = "POST /echo",
.on_data = on_data,
.on_done = on_done,
};
xHttpMuxHandle(mux, &route);
xHttpServerConf sconf = {0};
sconf.resolve = xHttpMuxResolve;
sconf.router = mux;
xHttpServer server = xHttpServerCreate(&sconf);
xHttpServerListen(server, "0.0.0.0", 9090);
printf("Echo on :9090\n");
xEventLoopRun(loop);
xHttpServerDestroy(server);
xHttpMuxDestroy(mux);
xEventLoopDestroy(loop);
return 0;
}
When
on_doneis called, the request body has been fully delivered viaon_data. Allocate the per-request state inon_request(or lazily inon_data) and free it inon_done.
Path parameters
static int on_get_user(xHttpCtx *ctx, void *arg) {
(void)arg;
size_t id_len = 0;
const char *id = xHttpCtxParam(ctx, "id", &id_len);
char body[128];
int n = snprintf(body, sizeof(body),
"{\"user_id\": \"%.*s\"}\n", (int)id_len, id);
xHttpCtxSetHeader(ctx, "Content-Type", "application/json");
xHttpCtxSend(ctx, body, (size_t)n);
return 0;
}
/* ... */
xHttpRouteConf route = {
.pattern = "GET /users/:id",
.on_request = on_get_user,
};
xHttpMuxHandle(mux, &route);
Server-Sent Events (streaming response)
static int on_events(xHttpCtx *ctx, void *arg) {
(void)arg;
xHttpCtxSetHeader(ctx, "Content-Type", "text/event-stream");
xHttpCtxSetHeader(ctx, "Cache-Control", "no-cache");
xHttpCtxWrite(ctx, "data: hello\n\n", 13);
xHttpCtxWrite(ctx, "data: world\n\n", 13);
/* Stream auto-ends when on_request returns. */
return 0;
}
xHttpRouteConf route = {
.pattern = "GET /events",
.on_request = on_events,
};
xHttpMuxHandle(mux, &route);
For long-lived streams driven by external events, call xHttpCtxYield(ctx) in on_request, write chunks from other callbacks with xHttpCtxWrite, and call xHttpCtxResume(ctx) when finished (see Yielded responses below).
Yielded responses
When the response cannot be sent synchronously from on_request/on_done — for example, it depends on another async operation (a database query, a sub-request via xHttpClient) — call xHttpCtxYield(ctx) to prevent the auto-200, then later call xHttpCtxSend (or xHttpCtxWrite + xHttpCtxResume) from a callback.
static int on_start_async(xHttpCtx *ctx, void *arg) {
(void)arg;
xHttpCtxYield(ctx); /* don't auto-respond on return */
/* Stash ctx somewhere and continue the work from another callback.
* When ready:
xHttpCtxSetHeader(ctx, "Content-Type", "text/plain");
xHttpCtxSend(ctx, "done\n", 5);
xHttpCtxResume(ctx);
*/
return 0;
}
HTTPS server
xHttpMux mux = xHttpMuxCreate();
/* ... xHttpMuxHandle(mux, &route) ... */
xHttpServerConf sconf = {0};
sconf.resolve = xHttpMuxResolve;
sconf.router = mux;
xHttpServer server = xHttpServerCreate(&sconf);
xTlsConf tls = {
.cert = "/path/to/server.pem",
.key = "/path/to/server-key.pem",
};
xHttpServerListenTls(server, "0.0.0.0", 8443, &tls);
HTTPS with mutual TLS (mTLS)
xTlsConf tls = {
.cert = "/path/to/server.pem",
.key = "/path/to/server-key.pem",
.ca = "/path/to/ca.pem", /* enables client cert verification */
};
xHttpServerListenTls(server, "0.0.0.0", 8443, &tls);
HTTP + HTTPS on different ports
xHttpServerListen(server, "0.0.0.0", 8080);
xHttpServerListenTls(server,"0.0.0.0", 8443, &tls);
Routes are shared — the same xHttpMux serves both listeners.
Multiple routes with shared state
typedef struct { int counter; } AppState;
static int on_count(xHttpCtx *ctx, void *arg) {
AppState *s = arg;
s->counter++;
char body[64];
int n = snprintf(body, sizeof(body), "{\"count\": %d}\n", s->counter);
xHttpCtxSetHeader(ctx, "Content-Type", "application/json");
xHttpCtxSend(ctx, body, (size_t)n);
return 0;
}
static int on_health(xHttpCtx *ctx, void *arg) {
(void)arg;
xHttpCtxSend(ctx, "ok\n", 3);
return 0;
}
/* ... */
AppState state = {0};
xHttpRouteConf count_route = {
.pattern = "POST /count",
.on_request = on_count,
.arg = &state,
};
xHttpMuxHandle(mux, &count_route);
xHttpRouteConf health_route = {
.pattern = "GET /health",
.on_request = on_health,
};
xHttpMuxHandle(mux, &health_route);
Custom resolver (skip the mux)
The resolver is just a function pointer — you can dispatch on any criterion without using xHttpMux:
static const xHttpRouteInfo *my_resolve(void *router, xHttpCtx *ctx) {
(void)router;
if (strcmp(ctx->url, "/health") == 0) {
static const xHttpRouteInfo r = { .on_request = on_health };
return &r;
}
return NULL; /* 404 */
}
xHttpServerConf sconf = { .resolve = my_resolve };
xHttpServer server = xHttpServerCreate(&sconf);
Best Practices
- Don't block in handlers. All callbacks run on the event loop thread. Blocking delays every other connection.
- Always call
xHttpCtxSend()orxHttpCtxWrite(). Ifon_donereturns without writing, a default 200 OK with empty body is sent automatically — but it's better to be explicit. - Don't mix
SendandWrite.Sendis for one-shot responses (setsContent-Length);Writeis for streaming (noContent-Length). They are mutually exclusive. - Use
xHttpCtxYield()for async responses. It prevents the auto-200 and lets you respond from a later callback. Always pair withxHttpCtxResume()when done. - Configure limits before listening.
xHttpServerSetMaxHeaderSize()and theidle_timeout_ms/max_header_sizefields ofxHttpServerConfmust be set beforeListen/ListenTls. - Register routes before listening. Add all
xHttpMuxHandle()calls beforexHttpServerListen()— the mux is read on every request. - Free per-request state in
on_done. Memory allocated inon_requestoron_datafor a single request should be freed inon_done. - Copy data you need to keep.
xHttpCtxpointers (method,url,headers) and thedatapointer inon_dataare valid only during the callback. - Destroy server before event loop.
xHttpServerDestroy()closes all connections and frees all resources.
Comparison with Other Libraries
| Feature | xhttp server.h | libuv + http-parser | libmicrohttpd | Go net/http | Node.js http |
|---|---|---|---|---|---|
| I/O Model | Async (event loop) | Async (event loop) | Threaded / select | Goroutines | Async (event loop) |
| HTTP Parser | llhttp (H1) + nghttp2 (H2) | http-parser / llhttp | Internal | Internal | llhttp |
| Streaming Request Body | on_data callback | Manual | Manual | Body reader | data event |
| Streaming Response | xHttpCtxWrite | Manual | Manual | Flusher | write |
| Routing | Pluggable resolver + xHttpMux | None | None | ServeMux | None |
| Keep-Alive | Automatic | Manual | Automatic | Automatic | Automatic |
| HTTP/2 | h2c + h2 (ALPN) | Manual | No | Yes | No |
| TLS/HTTPS | Built-in (ListenTls, mTLS) | Manual | Built-in | Built-in | Built-in |
| Language | C99 | C | C | Go | JavaScript |
Key Differentiator: xhttp server combines a single-threaded event-loop model with a decoupled resolver pattern, streaming request/response bodies, and built-in HTTP/1.1 + HTTP/2 (h2c + ALPN) on the same port. Routing is not bolted onto the server object — any function matching xHttpResolveFunc can dispatch requests, so the same server can serve a xHttpMux, a hand-written dispatcher, or a hybrid.
Implementation Details
Connection Lifecycle
stateDiagram-v2
[*] --> Accepted: accept() on listen fd
Accepted --> Reading: xSocket registered (Read)
Reading --> Parsing: Data received
Parsing --> HeadersDone: All headers parsed
HeadersDone --> Resolve: call resolve(router, ctx)
Resolve --> HandlerRunning: route matched
Resolve --> ErrorSent: NULL → 404
HandlerRunning --> StreamingBody: on_data chunks
StreamingBody --> HandlerDone: on_done fires
HandlerDone --> ResponseQueued: xHttpCtxSend / Write
ResponseQueued --> Flushing: conn_try_flush()
Flushing --> KeepAlive: All written + keep-alive
Flushing --> Backpressure: EAGAIN (register Write)
Backpressure --> Flushing: Write event fires
KeepAlive --> Reading: Reset parser state
Flushing --> Closed: All written + !keep-alive
ErrorSent --> Closed: Error responses close connection
Reading --> Closed: Idle timeout
Reading --> Closed: Client disconnect
Reading --> Closed: Parse error (400)
Parsing --> ErrorSent: Header too large (431)
Request Parsing Flow
sequenceDiagram
participant Client
participant Conn as xHttpConn_
participant Proto as xHttpProto (vtable)
participant Parser as proto_h1 (llhttp)
participant Bufs as xBuffer (url/headers)
participant Router as Resolver
participant Handler as User Callbacks
Client->>Conn: TCP data
Conn->>Proto: proto.on_data(data)
Proto->>Parser: llhttp_execute(data)
Parser->>Bufs: on_url → xBufferAppend(url)
Parser->>Bufs: on_header_field → xBufferAppend(headers_raw)
Parser->>Bufs: on_header_value → xBufferAppend(headers_raw)
Proto->>Handler: on_request(ctx) [headers complete]
Parser->>Handler: on_body → on_data(chunk)
Parser->>Proto: on_message_complete
Proto->>Handler: on_done(ctx)
Handler->>Conn: xHttpCtxSend / xHttpCtxWrite
Conn->>Client: HTTP response (async flush)
Routing
- Path match — Segment-by-segment comparison. Static segments require exact match;
:paramsegments match any non-empty string and capture the value. - Method match — Case-insensitive. A pattern without a method prefix (e.g.
"/any") matches any HTTP method. - Fallback — Path matches but no method matches → 405 Method Not Allowed. No path matches → 404 Not Found. Resolver returns NULL → 404.
- Parameter access — Inside a handler, call
xHttpCtxParam(ctx, "id", &len)to retrieve the captured value.
Response Serialization
When xHttpCtxSend() is called:
- Status line (
HTTP/1.1 <code> <reason>\r\n) is written to thexIOBuffer. Content-Lengthheader is added automatically.Connection: keep-aliveorConnection: closeis added based on the parser's determination.- User-set headers are appended.
- Header section is terminated with
\r\n. - Body is appended.
conn_try_flush()attempts an immediatewritev(). IfEAGAIN, the socket is registered for write events and flushing continues asynchronously.
For xHttpCtxWrite(), the first call flushes status + headers (with Transfer-Encoding: chunked for HTTP/1.1); subsequent calls append chunked data. For HTTP/2, xHttpCtxWrite submits DATA frames.
Keep-Alive & Pipelining
- HTTP/1.1 connections default to keep-alive. After a response is fully flushed, the parser is reset and the connection waits for the next request.
- The parser is paused on
on_message_completeto prevent parsing the next pipelined request before the current response is sent. - Error responses always set
Connection: close.
HTTP/2 Support (h2c Prior Knowledge)
The server supports cleartext HTTP/2 (h2c) via the Prior Knowledge mechanism. HTTP/1.1 and HTTP/2 coexist on the same port — no TLS or Upgrade header required.
Protocol Detection
When a new connection is accepted, protocol detection is deferred until the first bytes arrive:
- If the first 24 bytes match the HTTP/2 connection preface (
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n),xHttpProtoH2Init()is called. - Otherwise,
xHttpProtoH1Init()is called. - If fewer than 24 bytes have arrived but the prefix matches so far, the server waits for more data.
Stream Multiplexing
Under HTTP/2, a single TCP connection carries multiple concurrent streams:
xHttpStream_— Per-request state (URL, headers, response state). HTTP/1.1 uses a single implicit stream (stream_id = 0); HTTP/2 creates a new stream for each request.- Deferred dispatch — Completed streams are queued during
nghttp2_session_mem_recv()and dispatched after it returns, avoiding re-entrancy. - Response framing — Responses are submitted via
nghttp2_submit_response()with HPACK-compressed headers and DATA frames.
Key Differences: H1 vs H2
| Feature | HTTP/1.1 (proto_h1) | HTTP/2 (proto_h2) |
|---|---|---|
| Parser | llhttp (byte stream → request) | nghttp2 (byte stream → frame → stream) |
| Multiplexing | None (pipelining at best) | Native, multiple concurrent streams |
| Headers | Plain text Key: Value | HPACK compressed pseudo-headers + regular headers |
| Keep-alive | Connection: keep-alive header | Always persistent (multiplexed) |
| Response framing | Raw HTTP/1.1 status line + headers + body | nghttp2_submit_response() → HEADERS + DATA frames |
| Flow control | None | Built-in per-stream flow control |
Idle Timeout
Each connection has an idle timeout (default 60 s). If no data is received within this period, the connection is closed automatically. The timeout is reset after each response is sent on a keep-alive connection.
Relationship with Other Modules
- xbase — Uses
xEventLoopfor I/O multiplexing,xSocketfor non-blocking socket management, and socket timeouts for idle connection detection. - xbuf — Uses
xBufferfor request parsing accumulation (URL, headers) andxIOBufferfor read/write buffering with scatter-gather I/O. - xnet — Provides
xTlsConfand the TLS backend abstraction used byxHttpServerListenTls. - llhttp — External dependency. Incremental HTTP/1.1 parsing via callbacks, isolated behind the
xHttpProtovtable inproto_h1.c. - nghttp2 — External dependency. HTTP/2 frame processing, HPACK header compression, and stream management, isolated behind the
xHttpProtovtable inproto_h2.c. - OpenSSL / Mbed TLS — External dependency (TLS backend, compile-time selection via
X_TLS_BACKEND). Provides TLS handshake, encryption, certificate verification, and ALPN negotiation forxHttpServerListenTls().
ws.h — WebSocket Server
Introduction
ws.h provides a callback-driven WebSocket interface integrated with the xhttp server. For pure WebSocket services, call xWsServe() to create a server in one line. For mixed HTTP + WebSocket endpoints, register a route on an xHttpMux and call xWsUpgrade() from the route's on_request (or on_done) callback to perform the RFC 6455 upgrade handshake. The library handles frame codec, ping/pong, fragment reassembly, and close negotiation automatically.
All callbacks are dispatched on the event loop thread — no locks or thread pools required.
Design Philosophy
-
Handler-Initiated Upgrade — WebSocket connections start as regular HTTP requests. The user calls
xWsUpgrade(ctx, ...)inside a route callback (on_requestoron_done) to perform the upgrade. This keeps routing unified: WebSocket endpoints are justxHttpMuxroutes. -
Callback-Driven I/O — Three optional callbacks (
on_open,on_message,on_close) cover the full connection lifecycle. The library handles all framing, masking, and control frames internally. -
Automatic Protocol Handling — Ping/pong is answered automatically. Fragmented messages are reassembled before delivery. Close handshake follows RFC 6455 §5.5.1 with a 5-second timeout for the peer's response.
-
Connection Hijacking — On successful upgrade, the HTTP connection's socket and transport layer are transferred to a new
xWsConnobject. The HTTP connection is destroyed; the WebSocket connection takes full ownership of the file descriptor. -
Pluggable Crypto Backend — The handshake requires SHA-1 and Base64 for
Sec-WebSocket-Acceptcomputation. The crypto backend is selected at compile time: OpenSSL, Mbed TLS, or a built-in implementation.
Architecture
graph TD
subgraph "Application"
APP["User Code"]
ROUTE["xHttpMux route<br/>(on_request or on_done)"]
WS_CBS["xWsCallbacks"]
end
subgraph "xhttp WebSocket"
UPGRADE["xWsUpgrade(ctx, ...)"]
HANDSHAKE["Handshake<br/>(RFC 6455 §4)"]
CRYPTO["SHA-1 + Base64<br/>(pluggable backend)"]
WSCONN["xWsConn"]
PARSER["Frame Parser<br/>(incremental)"]
ENCODER["Frame Encoder"]
FRAG["Fragment<br/>Reassembly"]
CTRL["Control Frames<br/>(Ping/Pong/Close)"]
end
subgraph "xhttp Server"
SERVER["xHttpServer"]
MUX["xHttpMux"]
RESOLVER["xHttpMuxResolve"]
end
subgraph "xbase"
LOOP["xEventLoop"]
SOCK["xSocket"]
TIMER["Idle Timer"]
end
APP -->|"xHttpMuxHandle"| MUX
MUX --> RESOLVER
APP -->|"xWsServe (one-liner)"| SERVER
SERVER --> RESOLVER
RESOLVER --> ROUTE
ROUTE -->|"xWsUpgrade(ctx, cbs, arg)"| UPGRADE
UPGRADE --> HANDSHAKE
HANDSHAKE --> CRYPTO
HANDSHAKE -->|"101 Switching Protocols"| WSCONN
WSCONN --> PARSER
WSCONN --> ENCODER
PARSER --> FRAG
PARSER --> CTRL
FRAG -->|"on_message"| WS_CBS
CTRL -->|"auto pong"| ENCODER
WSCONN --> SOCK
SOCK --> LOOP
TIMER --> LOOP
style WSCONN fill:#4a90d9,color:#fff
style LOOP fill:#50b86c,color:#fff
style PARSER fill:#9b59b6,color:#fff
style HANDSHAKE fill:#f5a623,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xWsConn | Opaque WebSocket connection handle |
xWsOpcode | Message type: Text (0x1), Binary (0x2) |
xWsCallbacks | Struct of 3 optional callback pointers |
xWsConnectConf | Client-side config (see ws_client.md) |
Callback Signatures
xWsOnOpenFunc
typedef void (*xWsOnOpenFunc)(xWsConn conn, void *arg);
Called when the WebSocket connection is established. conn is valid until on_close returns.
xWsOnMessageFunc
typedef void (*xWsOnMessageFunc)(
xWsConn conn, xWsOpcode opcode,
const void *payload, size_t len,
void *arg);
Called when a complete message is received. Fragmented messages are reassembled before delivery. payload is valid only during the callback.
xWsOnCloseFunc
typedef void (*xWsOnCloseFunc)(
xWsConn conn, uint16_t code,
const char *reason, size_t len,
void *arg);
Called when the connection is closed (clean or abnormal). After this callback returns, conn is invalid.
xWsCallbacks
XDEF_STRUCT(xWsCallbacks) {
xWsOnOpenFunc on_open; /* optional */
xWsOnMessageFunc on_message; /* optional */
xWsOnCloseFunc on_close; /* optional */
};
Functions
| Function | Description |
|---|---|
xWsServe | One-call WebSocket-only server |
xWsUpgrade | Upgrade HTTP → WebSocket (call from a route callback) |
xWsSend | Send a text or binary message |
xWsClose | Initiate graceful close |
xWsServe
xHttpServer xWsServe(
const char *host,
uint16_t port,
const xWsCallbacks *callbacks,
void *arg);
Convenience function that creates an HTTP server with a built-in xHttpMux, registers a catch-all GET / route that upgrades every incoming request to WebSocket, and starts listening. Internally it builds an xHttpServerConf with xHttpMuxResolve, creates the server, and calls xHttpServerListen().
Returns the server handle for later cleanup via xHttpServerDestroy(), or NULL on failure. The mux and route context are freed automatically when the server is destroyed — do not call xHttpMuxDestroy() yourself.
Parameters:
host— Bind address (e.g."0.0.0.0"), or NULL.port— Port number to listen on.callbacks— WebSocket event callbacks (not NULL).arg— User argument forwarded to all callbacks.
Returns: Server handle, or NULL on failure.
xWsUpgrade
xErrno xWsUpgrade(
xHttpCtx *ctx,
const xWsCallbacks *callbacks,
void *arg);
Call from a route's on_request or on_done callback to upgrade the HTTP connection to WebSocket. On success, the handler must return immediately — the HTTP connection has been hijacked and the xHttpCtx* is no longer valid.
xWsUpgrade validates the request headers (Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version) and sends the 101 Switching Protocols response automatically. On failure (missing headers, wrong version, etc.) an appropriate HTTP error response (400/405) is sent and a non-Ok error code is returned — the handler may then return normally.
Parameters:
ctx— The request context from the route callback.callbacks— WebSocket event callbacks (not NULL).arg— User argument forwarded to all callbacks.
Returns: xErrno_Ok on success.
xWsSend
xErrno xWsSend(
xWsConn conn, xWsOpcode opcode,
const void *payload, size_t len);
Send a message over the WebSocket connection. The payload is framed and queued for asynchronous transmission.
Returns: xErrno_Ok on success, xErrno_InvalidState if the connection is closing.
xWsClose
xErrno xWsClose(xWsConn conn, uint16_t code);
Initiate a graceful close. Sends a Close frame with the given status code. The connection remains open until the peer responds or a 5-second timeout expires.
Close Status Codes
| Code | Constant | Meaning |
|---|---|---|
| 1000 | XWS_CLOSE_NORMAL | Normal closure |
| 1001 | XWS_CLOSE_GOING_AWAY | Server shutting down |
| 1002 | XWS_CLOSE_PROTOCOL_ERR | Protocol error |
| 1003 | XWS_CLOSE_UNSUPPORTED | Unsupported data |
| 1005 | XWS_CLOSE_NO_STATUS | No status received |
| 1006 | XWS_CLOSE_ABNORMAL | Abnormal closure |
Usage Examples
Echo server (one-liner with xWsServe)
#include <x/base/event.h>
#include <x/http/ws.h>
#include <stdio.h>
#include <string.h>
static void on_open(xWsConn conn, void *arg) {
(void)arg;
const char *hi = "Welcome!";
xWsSend(conn, xWsOpcode_Text, hi, strlen(hi));
}
static void on_message(xWsConn conn, xWsOpcode op, const void *data, size_t len, void *arg) {
(void)arg;
xWsSend(conn, op, data, len);
}
static void on_close(xWsConn conn, uint16_t code, const char *reason, size_t len, void *arg) {
(void)conn; (void)reason; (void)len; (void)arg;
printf("closed: %u\n", code);
}
static const xWsCallbacks ws_cbs = {
.on_open = on_open,
.on_message = on_message,
.on_close = on_close,
};
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpServer srv = xWsServe("0.0.0.0", 8080, &ws_cbs, NULL);
if (!srv) return 1;
printf("ws://localhost:8080/\n");
xEventLoopRun(loop);
xHttpServerDestroy(srv);
xEventLoopDestroy(loop);
return 0;
}
Echo server (with xWsUpgrade + xHttpMux)
Use this pattern when you need mixed HTTP + WebSocket endpoints on the same server. Register the WebSocket route on a xHttpMux alongside ordinary HTTP routes, and call xWsUpgrade(ctx, ...) from the route callback.
#include <x/base/event.h>
#include <x/http/server.h>
#include <x/http/ws.h>
#include <stdio.h>
#include <string.h>
static const xWsCallbacks ws_cbs = {
.on_open = on_open,
.on_message = on_message,
.on_close = on_close,
};
/* Route callback — invoked by xHttpMuxResolve after the request is complete.
* Type is xHttpDoneFunc (or xHttpInitFunc, depending on which field you
* register it in). Receives xHttpCtx*. */
static void ws_handler(xHttpCtx *ctx, void *arg) {
(void)arg;
xErrno err = xWsUpgrade(ctx, &ws_cbs, NULL);
if (err != xErrno_Ok) {
/* xWsUpgrade already sent a 400/405; just return. */
return;
}
/* On success the connection has been hijacked — do not touch ctx. */
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpMux mux = xHttpMuxCreate();
xHttpRouteConf ws_route = {
.pattern = "GET /ws",
.on_done = ws_handler, /* or on_request */
};
xHttpMuxHandle(mux, &ws_route);
/* You can register plain HTTP routes on the same mux. */
xHttpRouteConf health_route = {
.pattern = "GET /health",
.on_request = on_health, /* a normal xHttpInitFunc handler */
};
xHttpMuxHandle(mux, &health_route);
xHttpServerConf sconf = {0};
sconf.resolve = xHttpMuxResolve;
sconf.router = mux;
xHttpServer srv = xHttpServerCreate(&sconf);
xHttpServerListen(srv, "0.0.0.0", 8080);
printf("ws://localhost:8080/ws\n");
xEventLoopRun(loop);
xHttpServerDestroy(srv);
xHttpMuxDestroy(mux);
xEventLoopDestroy(loop);
return 0;
}
Register the upgrade handler in either
on_request(fires right after headers) oron_done(fires after the full request body is received). For a GET-based WebSocket Upgrade there is no body, so both fire essentially back-to-back — pick whichever matches your preference. The one-linerxWsServe()useson_doneinternally.
Per-connection user data
Allocate a per-connection state in the route handler and pass it as arg to xWsUpgrade. Free it in on_close.
typedef struct {
char username[64];
int msg_count;
} Session;
static void on_open(xWsConn conn, void *arg) {
Session *s = arg;
snprintf(s->username, sizeof(s->username), "user_%p", (void *)conn);
s->msg_count = 0;
}
static void on_message(xWsConn conn, xWsOpcode op, const void *data, size_t len, void *arg) {
Session *s = arg;
s->msg_count++;
printf("[%s] msg #%d: %.*s\n", s->username, s->msg_count, (int)len, (const char *)data);
xWsSend(conn, op, data, len);
}
static void on_close(xWsConn conn, uint16_t code, const char *reason, size_t len, void *arg) {
(void)conn; (void)code; (void)reason; (void)len;
Session *s = arg;
printf("[%s] disconnected\n", s->username);
free(s);
}
static void ws_handler(xHttpCtx *ctx, void *arg) {
(void)arg;
Session *s = calloc(1, sizeof(Session));
if (!s) return;
xWsCallbacks cbs = {
.on_open = on_open,
.on_message = on_message,
.on_close = on_close,
};
xWsUpgrade(ctx, &cbs, s);
}
Graceful server-initiated close
static void on_message(xWsConn conn, xWsOpcode op, const void *data, size_t len, void *arg) {
(void)op; (void)arg;
if (len == 4 && memcmp(data, "quit", 4) == 0) {
xWsClose(conn, 1000); /* normal close */
return;
}
xWsSend(conn, op, data, len);
}
JavaScript client
<script>
const ws = new WebSocket('ws://localhost:8080/ws');
ws.onopen = () => console.log('connected');
ws.onmessage = (e) => console.log('< ' + e.data);
ws.onclose = (e) =>
console.log('closed: ' + e.code);
// Send a message
ws.send('Hello, server!');
</script>
Best Practices
- Return immediately after
xWsUpgrade()succeeds. On success the HTTP connection is hijacked — thexHttpCtx*is no longer valid and you must not call anyxHttpCtx*functions afterward. - Don't block in callbacks. All callbacks run on the event loop thread. Blocking delays all other I/O.
- Copy payload if needed. The
payloadpointer inon_messageis valid only during the callback. Copy the data if you need it later. - Use
xWsClose()for graceful shutdown. Avoid dropping connections without a Close handshake. - Handle
on_closefor cleanup. Free per-connection resources inon_close, as thexWsConnhandle becomes invalid after the callback returns. - Idle timeout is set on the server. The WebSocket connection inherits the
xHttpServerConf.idle_timeout_mssetting. Adjust it when creating the server if you need longer-lived connections.
Comparison with Other Libraries
| Feature | xhttp WS | libwebsockets | uWebSockets |
|---|---|---|---|
| Integration | xEventLoop | Own loop | Own loop |
| Upgrade | In HTTP route callback | Separate | Separate |
| Fragment reassembly | Automatic | Automatic | Automatic |
| Ping/Pong | Automatic | Automatic | Automatic |
| Close handshake | RFC 6455 | RFC 6455 | RFC 6455 |
| TLS | Via xhttp | Built-in | Built-in |
| Language | C99 | C | C++ |
| Dependencies | xbase only | OpenSSL | None |
Key Differentiator: xhttp's WebSocket server is unique in its handler-initiated upgrade pattern. Instead of a separate WebSocket server, you register a normal xHttpMux route and call xWsUpgrade(ctx, ...) inside the route callback. This keeps routing, middleware, and mixed HTTP+WS endpoints unified under a single xHttpServer instance and a single xHttpMux.
Implementation Details
Upgrade Handshake Flow
sequenceDiagram
participant Client as Browser
participant Mux as xHttpMux
participant Handler as Route Callback
participant Upgrade as xWsUpgrade()
participant Conn as xHttpConn_
participant WS as xWsConn
Client->>Mux: GET /ws (Upgrade: websocket)
Mux->>Handler: on_request(ctx, arg) or on_done(ctx, arg)
Handler->>Upgrade: xWsUpgrade(ctx, &cbs, arg)
Upgrade->>Upgrade: Validate headers
Note over Upgrade: Method=GET<br/>Upgrade: websocket<br/>Connection: Upgrade<br/>Sec-WebSocket-Version: 13<br/>Sec-WebSocket-Key: ...
Upgrade->>Upgrade: SHA1(Key + GUID) → Base64
Upgrade->>Client: 101 Switching Protocols
Upgrade->>Conn: Hijack socket + transport
Upgrade->>WS: xWsConnCreate()
WS->>Client: on_open callback fires
Connection Lifecycle
stateDiagram-v2
[*] --> Open: xWsUpgrade() succeeds
Open --> Open: Data frames (text/binary)
Open --> Open: Ping → auto Pong
Open --> CloseSent: xWsClose() called
Open --> CloseReceived: Peer sends Close
CloseSent --> Closed: Peer Close received
CloseSent --> Closed: 5s timeout
CloseReceived --> Closed: Echo Close flushed
Open --> Closed: I/O error
Open --> CloseSent: Idle timeout (1001)
Closed --> [*]: on_close + destroy
Frame Processing
When data arrives on the socket, the incremental frame parser (xWsFrameParser) extracts complete frames from the xIOBuffer. Each frame is processed based on its opcode:
| Opcode | Handling |
|---|---|
| Text (0x1) | Deliver via on_message |
| Binary (0x2) | Deliver via on_message |
| Continuation (0x0) | Append to fragment buffer |
| Ping (0x9) | Auto-reply with Pong |
| Pong (0xA) | Ignored |
| Close (0x8) | Close handshake |
Fragment Reassembly
Fragmented messages are reassembled transparently:
- First fragment (FIN=0, opcode=Text/Binary) starts accumulation in
frag_buf. - Continuation frames (opcode=0x0) append to
frag_buf. - Final fragment (FIN=1, opcode=0x0) triggers reassembly and delivers the complete message via
on_message.
Protocol violations (e.g., new message mid-fragment) result in a Close frame with status 1002.
Close State Machine
XDEF_ENUM(xWsCloseState){
xWsCloseState_Open, // Normal operating state
xWsCloseState_CloseSent, // We sent Close, waiting for peer
xWsCloseState_CloseReceived, // Peer sent Close, we replied
xWsCloseState_Closed, // Connection fully closed
};
- Server-initiated close:
xWsClose()sends a Close frame and transitions toCLOSE_SENT. A 5-second timer waits for the peer's Close response. - Peer-initiated close: The peer's Close frame is echoed back, transitioning to
CLOSE_RECEIVED. After the echo is flushed,on_closefires and the connection is destroyed. - Idle timeout: After the configured idle period with no data, a Close frame with code 1001 (Going Away) is sent.
Internal File Structure
| File | Role |
|---|---|
ws.h | Public API (types, callbacks, functions) |
ws.c | Connection lifecycle, I/O, frame dispatch |
ws_handshake_server.c | Server upgrade handshake (RFC 6455 §4.2) |
ws_frame.h/c | Frame codec (parse + encode) |
ws_crypto.h | SHA-1 + Base64 interface |
ws_crypto_openssl.c | OpenSSL backend |
ws_crypto_mbedtls.c | Mbed TLS backend |
ws_crypto_builtin.c | Built-in (no TLS dep) |
ws_serve.c | xWsServe() convenience wrapper |
ws_private.h | Internal data structures |
ws.h — WebSocket Client
Introduction
ws.h provides xWsConnect(), an asynchronous WebSocket client that integrates with xbase's event loop. The entire connection process — DNS resolution, TCP connect, optional TLS handshake, and HTTP Upgrade — runs fully asynchronously. Once connected, the same callback-driven model (on_open, on_message, on_close) and the same xWsConn handle are used for both client and server connections.
Design Philosophy
-
Fully Asynchronous Connection —
xWsConnect()returns immediately. The multi-phase connection process (DNS → TCP → TLS → HTTP Upgrade) is driven entirely by the event loop. No threads or blocking calls. -
Shared Connection Model — Once the handshake completes, a client
xWsConnis identical to a serverxWsConn. The samexWsSend(),xWsClose(), and callback interfaces apply. Code that operates onxWsConndoesn't need to know which side initiated the connection. -
Failure via
on_close— If the connection fails at any stage (DNS, TCP, TLS, or HTTP Upgrade),on_closeis invoked with an error code.on_openis never called for failed connections. Cleanup always happens in one place. -
Client-Side Masking — Per RFC 6455, client-to-server frames must be masked. The library handles this automatically when the connection is created in client mode.
Architecture
graph TD
subgraph "Application"
APP["User Code"]
CBS["xWsCallbacks"]
CONF["xWsConnectConf"]
end
subgraph "xWsConnect State Machine"
CONNECT["xWsConnect()"]
DNS["DNS Resolution"]
TCP["TCP Connect"]
TLS["TLS Handshake<br/>(wss:// only)"]
UPGRADE["HTTP Upgrade<br/>Request/Response"]
VALIDATE["Validate 101<br/>+ Sec-WebSocket-Accept"]
end
subgraph "Established Connection"
WSCONN["xWsConn<br/>(client mode)"]
SEND["xWsSend()"]
CLOSE["xWsClose()"]
end
subgraph "xbase"
LOOP["xEventLoop"]
SOCK["xSocket"]
TIMER["Timeout Timer"]
end
APP --> CONF
APP --> CBS
CONF --> CONNECT
CBS --> CONNECT
CONNECT --> DNS
DNS --> TCP
TCP --> TLS
TLS --> UPGRADE
UPGRADE --> VALIDATE
VALIDATE -->|"Success"| WSCONN
VALIDATE -->|"Failure"| CBS
WSCONN --> SEND
WSCONN --> CLOSE
WSCONN --> SOCK
SOCK --> LOOP
TIMER --> LOOP
style WSCONN fill:#4a90d9,color:#fff
style LOOP fill:#50b86c,color:#fff
style CONNECT fill:#f5a623,color:#fff
style VALIDATE fill:#9b59b6,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xWsConn | Opaque WebSocket connection handle (shared with server) |
xWsOpcode | Message type: Text (0x1), Binary (0x2) |
xWsCallbacks | Struct of 3 optional callback pointers (shared with server) |
xWsConnectConf | Configuration for xWsConnect() |
xWsConnectConf
XDEF_STRUCT(xWsConnectConf) {
const char *url; /* ws:// or wss:// URL (required) */
const xTlsConf *tls; /* TLS config for wss:// (NULL = defaults) */
xTlsCtx tls_ctx; /* Pre-created shared TLS context (priority) */
const char *headers; /* Extra HTTP headers (NULL = none) */
int timeout_ms; /* Connect timeout (0 = 10000 ms) */
};
| Field | Description |
|---|---|
url | WebSocket URL. Must start with ws:// or wss://. Required. |
tls | TLS configuration for wss:// connections. NULL uses system CA with verification enabled. Ignored for ws://. Ignored when tls_ctx is set. |
tls_ctx | Pre-created shared TLS context from xTlsCtxCreate(). Takes priority over tls. The caller retains ownership and must keep it alive for the lifetime of the connection. NULL = create from tls (or use defaults). |
headers | Extra HTTP headers appended to the Upgrade request. Format: "Key: Value\r\nKey2: Value2\r\n". NULL for none. |
timeout_ms | Timeout for the entire connection process in milliseconds. 0 uses the default (10000 ms). |
Callbacks
The same xWsCallbacks struct is used for both client and server connections. See WebSocket Server for callback signature details.
Client-specific behavior:
on_open— Called when the connection is fully established (101 validated). Not called on failure.on_close— Called on connection failure (DNS, TCP, TLS, or Upgrade error) or after a normal close. For failed connections,connisNULL.
Functions
xWsConnect
xErrno xWsConnect(
const xWsConnectConf *conf,
const xWsCallbacks *callbacks,
void *arg);
Initiate an asynchronous WebSocket client connection. Returns immediately; the connection process runs on the event loop.
Parameters:
conf— Connection configuration (must not be NULL,conf->urlrequired).callbacks— WebSocket event callbacks (must not be NULL).arg— User argument forwarded to all callbacks.
Returns: xErrno_Ok if the async connection started, xErrno_InvalidArg for bad parameters (NULL pointers, invalid URL scheme).
xWsSend
xErrno xWsSend(
xWsConn conn, xWsOpcode opcode,
const void *payload, size_t len);
Send a message. Identical to the server-side API. Client frames are automatically masked per RFC 6455.
xWsClose
xErrno xWsClose(xWsConn conn, uint16_t code);
Initiate a graceful close. Identical to the server-side API.
Usage Examples
Connect and echo
#include <x/base/event.h>
#include <x/http/ws.h>
#include <stdio.h>
#include <string.h>
static void on_open(xWsConn conn, void *arg) {
(void)arg;
const char *msg = "Hello, server!";
xWsSend(conn, xWsOpcode_Text, msg, strlen(msg));
}
static void on_message(xWsConn conn, xWsOpcode op, const void *data, size_t len, void *arg) {
(void)conn; (void)op; (void)arg;
printf("Received: %.*s\n", (int)len, (const char *)data);
xWsClose(conn, 1000);
}
static void on_close(xWsConn conn, uint16_t code, const char *reason, size_t len, void *arg) {
(void)conn; (void)reason; (void)len; (void)arg;
printf("Closed: %u\n", code);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xWsConnectConf conf = {0};
conf.url = "ws://localhost:8080/ws";
xWsCallbacks cbs = {
.on_open = on_open,
.on_message = on_message,
.on_close = on_close,
};
xWsConnect(&conf, &cbs, NULL);
xEventLoopRun(loop);
xEventLoopDestroy(loop);
return 0;
}
Secure connection (wss://)
xTlsConf tls = {0};
tls.skip_verify = 1; /* dev only */
xWsConnectConf conf = {0};
conf.url = "wss://echo.example.com/ws";
conf.tls = &tls;
conf.timeout_ms = 5000;
xWsConnect(&conf, &cbs, NULL);
Shared TLS context (multiple connections)
When creating many wss:// connections (reconnect loops, connection pools), use a shared xTlsCtx to avoid reloading certificates on every connection:
xTlsConf tls = {0};
tls.ca = "ca.pem";
xTlsCtx ctx = xTlsCtxCreate(&tls);
/* All connections share the same ctx */
xWsConnectConf conf = {0};
conf.url = "wss://echo.example.com/ws";
conf.tls_ctx = ctx; /* shared, not copied */
xWsConnect(&conf, &cbs, NULL);
/* Destroy ctx only after all connections are closed. */
Custom headers (authentication)
xWsConnectConf conf = {0};
conf.url = "ws://api.example.com/stream";
conf.headers = "Authorization: Bearer token123\r\n"
"X-Client-Version: 1.0\r\n";
xWsConnect(&conf, &cbs, NULL);
Connection failure handling
static void on_close(xWsConn conn, uint16_t code, const char *reason, size_t len, void *arg) {
(void)reason; (void)len; (void)arg;
if (conn == NULL) {
/* Connection failed before the WebSocket was established */
printf("Connection failed (code %u)\n", code);
/* Optionally schedule a retry via xEventLoopPost / a timer */
return;
}
printf("Disconnected: %u\n", code);
}
Binary data
static void on_open(xWsConn conn, void *arg) {
(void)arg;
uint8_t data[] = {0x00, 0x01, 0x02, 0xFF, 0xFE};
xWsSend(conn, xWsOpcode_Binary, data, sizeof(data));
}
Best Practices
- Check the return value of
xWsConnect(). It returnsxErrno_InvalidArgfor obviously bad parameters (NULL pointers, unsupported URL scheme). Network errors are reported asynchronously viaon_close. - Handle
conn == NULLinon_close. This indicates a connection failure before the WebSocket was established. Use this to implement retry logic. - Don't block in callbacks. All callbacks run on the event loop thread.
- Copy payload if needed. The
payloadpointer inon_messageis valid only during the callback. - Use
xWsClose()for graceful shutdown. The client sends a Close frame and waits for the server's response. - Set a reasonable timeout. The default 10-second timeout covers DNS + TCP + TLS + Upgrade. Adjust via
conf.timeout_msfor high-latency networks. - Never use
skip_verifyin production. It disables all certificate validation. Use a proper CA path or system CA bundle instead.
Comparison with Other Libraries
| Feature | xhttp WS Client | libwebsockets | wslay | civetweb |
|---|---|---|---|---|
| I/O Model | Async (event loop) | Async (own loop) | Sync (user drives) | Threaded |
| Event Loop | xEventLoop | Own loop | None | pthreads |
| DNS | Async (xDnsResolve) | Async (built-in) | Manual | Blocking |
| TLS | Via xnet | Built-in | Manual | Built-in |
| Client Masking | Automatic | Automatic | Automatic | Automatic |
| Connection Timeout | Configurable | Configurable | Manual | Configurable |
| Language | C99 | C | C | C |
| Dependencies | xbase + xnet | OpenSSL | None | None |
Key Differentiator: xhttp's WebSocket client runs entirely on the xbase event loop with zero blocking calls. The multi-phase connection (DNS → TCP → TLS → Upgrade) is a single async state machine. Combined with the shared xWsConn model, client and server code use identical APIs for sending, receiving, and closing — making bidirectional WebSocket applications straightforward.
TLS Context Sharing: For wss:// connections, the client supports a shared xTlsCtx (via conf.tls_ctx) that avoids reloading certificates and re-creating the SSL context on every connection. This is the same pattern used by xTcpConnect and xTcpListener, providing consistent TLS context management across all libx networking APIs.
Implementation Details
Connection State Machine
The xWsConnector drives the connection through five phases, all on the event loop thread:
stateDiagram-v2
[*] --> DNS: xWsConnect() called
DNS --> TCP_CONNECT: Address resolved
TCP_CONNECT --> TLS_HANDSHAKE: Connected [wss]
TCP_CONNECT --> HTTP_UPGRADE_WRITE: Connected [ws]
TLS_HANDSHAKE --> HTTP_UPGRADE_WRITE: Handshake complete
HTTP_UPGRADE_WRITE --> HTTP_UPGRADE_READ: Request sent
HTTP_UPGRADE_READ --> DONE: 101 validated
DONE --> [*]: on_open fires
DNS --> [*]: Failure → on_close
TCP_CONNECT --> [*]: Failure → on_close
TLS_HANDSHAKE --> [*]: Failure → on_close
HTTP_UPGRADE_READ --> [*]: Bad response → on_close
DNS --> [*]: Timeout → on_close
TCP_CONNECT --> [*]: Timeout → on_close
Phase Details
| Phase | What Happens |
|---|---|
| DNS | xDnsResolve() resolves the hostname asynchronously. On success, proceeds to TCP. |
| TCP Connect | Creates an xSocket, calls connect(). Waits for the writable event (EINPROGRESS). |
| TLS Handshake | For wss:// URLs only. Initializes the TLS transport and drives the handshake via read/write events. |
| HTTP Upgrade Write | Builds the Upgrade request (with random Sec-WebSocket-Key) and flushes it to the server. |
| HTTP Upgrade Read | Reads the server's response, validates HTTP/1.1 101, Upgrade: websocket, Connection: Upgrade, and Sec-WebSocket-Accept. |
Handshake Flow
sequenceDiagram
participant App as Application
participant Conn as xWsConnector
participant DNS as xDnsResolve
participant Server as Remote Server
App->>Conn: xWsConnect(conf, cbs, arg)
Conn->>DNS: Resolve hostname
DNS-->>Conn: Address resolved
Conn->>Server: TCP connect()
Server-->>Conn: Connected
Note over Conn,Server: (wss:// only) TLS handshake
Conn->>Server: GET /path HTTP/1.1<br/>Upgrade: websocket<br/>Sec-WebSocket-Key: ...
Server-->>Conn: HTTP/1.1 101 Switching Protocols<br/>Sec-WebSocket-Accept: ...
Conn->>Conn: Validate response
Conn->>App: on_open(conn, arg)
Timeout Handling
A configurable timeout (default 10 seconds) covers the entire connection process. If any phase takes too long, the timer fires, the connector is destroyed, and on_close is invoked with code 1006 (Abnormal Closure).
Internal File Structure
| File | Role |
|---|---|
ws.h | Public API (xWsConnect, xWsConnectConf) |
ws_connect.c | Async connection state machine |
ws_handshake_client.h/c | Build Upgrade request, validate 101 response |
ws_crypto.h | SHA-1 + Base64 for Sec-WebSocket-Accept |
transport_tls_client.h | TLS client transport init (shared xTlsCtx → per-connection SSL) |
transport_tls_client_openssl.c | OpenSSL TLS client transport implementation |
transport_tls_client_mbedtls.c | mbedTLS TLS client transport implementation |
sse.c — SSE Stream Client
Introduction
sse.c implements Server-Sent Events (SSE) support for xHttpClient. It provides xHttpClientGetSse() and xHttpClientDoSse() which subscribe to SSE endpoints and parse the event stream according to the W3C SSE specification. Each parsed event is delivered to a callback as it arrives — ideal for LLM streaming integration.
Design Philosophy
-
W3C Spec Compliance — Field parsing (
event,data,id,retry), comment handling, multi-line data joining with\n, and default event type"message". -
Streaming Parse — Data is parsed incrementally as it arrives from libcurl's write callback. Complete lines are processed immediately; incomplete lines are buffered.
-
Shared Infrastructure — SSE requests reuse the same
curl_multihandle and event-loop integration as regular HTTP requests. ThexHttpReqVtablemechanism lets SSE plug in its own write callback and completion handler. -
POST + Request Body via
on_read—xHttpClientDoSse()takes a fullxHttpRequestConf, so the request body for POST-based SSE (LLM APIs) is streamed viaon_read— nobody/body_lenfields to keep alive. Setcontent_lengthforContent-Length, or leave it 0 for chunked. -
User-Controlled Cancellation — The
xSseEventFunccallback returns anint: 0 to continue, non-zero to close the connection.
Architecture
graph TD
subgraph "SSE Request Flow"
SUBMIT["xHttpClientDoSse()"]
EASY["curl_easy + SSE headers"]
READ["on_read<br/>(upload body, optional)"]
WRITE["sse_write_callback"]
PARSER["xSseParser_"]
EVENT["on_event(ev)"]
DONE["on_done(curl_code)"]
end
subgraph "Shared with Oneshot"
MULTI["curl_multi"]
LOOP["xEventLoop"]
CHECK["check_multi_info()"]
end
SUBMIT --> EASY
EASY --> MULTI
MULTI --> LOOP
READ --> EASY
LOOP -->|"fd ready"| WRITE
WRITE --> PARSER
PARSER -->|"event boundary"| EVENT
CHECK -->|"transfer done"| DONE
style PARSER fill:#4a90d9,color:#fff
style EVENT fill:#50b86c,color:#fff
style READ fill:#f5a623,color:#fff
API Reference
Types
| Type | Description |
|---|---|
xSseEvent | SSE event: event (type), data, id, retry |
xSseEventFunc | int (*)(const xSseEvent *ev, void *arg) — return 0 to continue, non-zero to close |
xSseDoneFunc | void (*)(int curl_code, void *arg) — called when stream ends |
xHttpRequestConf | Per-request config (used by DoSse) — URL, method, headers, on_read for body |
xSseEvent Fields
| Field | Type | Description |
|---|---|---|
event | const char * | Event type. "message" if omitted by server. |
data | const char * | Event data. Multi-line data joined by \n. |
id | const char * | Last event ID, or NULL. |
retry | int | Retry delay in ms, or -1 if not set. |
All strings are NUL-terminated and valid only during the callback.
Functions
| Function | Signature | Description |
|---|---|---|
xHttpClientGetSse | xErrno xHttpClientGetSse(xHttpClient client, const char *url, xSseEventFunc on_event, xSseDoneFunc on_done, void *arg) | Subscribe to a GET SSE endpoint. |
xHttpClientDoSse | xErrno xHttpClientDoSse(xHttpClient client, const xHttpRequestConf *config, xSseEventFunc on_event, xSseDoneFunc on_done, void *arg) | Fully-configured SSE request — POST + JSON body for LLM APIs. |
xHttpClientDoSse() automatically adds Accept: text/event-stream. User-provided headers in config->headers are merged after this default. The request body comes from config->on_read (with config->content_length providing the size, 0 for chunked) — there is no body/body_len field on xHttpRequestConf.
The arg passed to DoSse/GetSse is forwarded to all three callbacks: on_event, on_done, and on_read. A single struct holding both upload state and SSE state is the cleanest way to share context across them.
Usage Examples
Simple SSE subscription (GET)
#include <stdio.h>
#include <x/base/event.h>
#include <x/http/client.h>
static int on_event(const xSseEvent *ev, void *arg) {
(void)arg;
printf("[%s] %s\n", ev->event, ev->data);
return 0; /* continue */
}
static void on_done(int curl_code, void *arg) {
(void)arg;
printf("Stream ended (code=%d)\n", curl_code);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpClient client = xHttpClientCreate(NULL);
xHttpClientGetSse(client, "https://example.com/events",
on_event, on_done, NULL);
xEventLoopRun(loop);
xHttpClientDestroy(client);
xEventLoopDestroy(loop);
return 0;
}
LLM API streaming (POST with JSON body)
The request body is provided via on_read — the same callback type used for regular POST uploads. Set content_length to send Content-Length, or leave it 0 for chunked transfer.
#include <stdio.h>
#include <string.h>
#include <x/base/event.h>
#include <x/http/client.h>
/* Holds both upload state and SSE state — passed as `arg` to all
* three callbacks (on_read, on_event, on_done). */
struct StreamCtx {
/* upload state */
const char *payload;
size_t payload_len;
size_t off;
/* SSE state */
int got_done;
};
static size_t on_read_body(char *buf, size_t bufsize, void *arg) {
struct StreamCtx *c = arg;
size_t remaining = c->payload_len - c->off;
if (remaining == 0) return 0; /* EOF */
size_t n = bufsize < remaining ? bufsize : remaining;
memcpy(buf, c->payload + c->off, n);
c->off += n;
return n;
}
static int on_event(const xSseEvent *ev, void *arg) {
(void)arg;
if (strcmp(ev->data, "[DONE]") == 0) {
printf("\n--- Stream complete ---\n");
return 1; /* close connection */
}
printf("%s", ev->data);
fflush(stdout);
return 0;
}
static void on_done(int curl_code, void *arg) {
struct StreamCtx *c = arg;
c->got_done = 1;
if (curl_code != 0)
printf("\nStream error (code=%d)\n", curl_code);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpClient client = xHttpClientCreate(NULL);
static const char body[] =
"{"
" \"model\": \"gpt-4\","
" \"messages\": [{\"role\": \"user\", \"content\": \"Hello!\"}],"
" \"stream\": true"
"}";
struct StreamCtx c = { body, sizeof(body) - 1, 0, 0 };
const char *headers[] = {
"Content-Type: application/json",
"Authorization: Bearer sk-your-api-key",
NULL,
};
xHttpRequestConf conf = {0};
conf.url = "https://api.openai.com/v1/chat/completions";
conf.method = xHttpMethod_POST;
conf.content_length = c.payload_len;
conf.headers = headers;
conf.timeout_ms = 60000; /* connection-phase timeout */
conf.on_read = on_read_body;
xHttpClientDoSse(client, &conf, on_event, on_done, &c);
xEventLoopRun(loop);
xHttpClientDestroy(client);
xEventLoopDestroy(loop);
return 0;
}
Note:
on_read,on_event, andon_doneall receive the sameargpointer, so a singlestruct StreamCtxholding both the upload payload and any SSE-side state is the natural way to share context across them.
Early cancellation
Return non-zero from on_event to close the connection cleanly:
static int on_event(const xSseEvent *ev, void *arg) {
int *count = arg;
if (++*count >= 10) {
printf("Received 10 events, closing.\n");
return 1; /* non-zero = close */
}
printf("#%d: %s\n", *count, ev->data);
return 0;
}
Use Cases
- LLM API Integration — Stream responses from OpenAI, Anthropic, Google Gemini, or any OpenAI-compatible API. Use
xHttpClientDoSse()with POST + JSON body. - Real-Time Notifications — Subscribe to server push (chat messages, stock prices, IoT sensor data) via GET SSE endpoints.
- Log Streaming — Tail remote log streams delivered as SSE events.
Best Practices
- Use
xHttpClientDoSse()for LLM APIs. Most LLM APIs require POST with a JSON body and custom headers.GetSseis only for simple GET endpoints. - Handle
[DONE]signals. Many LLM APIs send a special[DONE]data payload to signal the end of the stream. Return non-zero fromon_eventto close cleanly. - Stream the request body via
on_read. Don't try to stuff the body into abody/body_lenfield —xHttpRequestConfhas none. Useon_read+content_lengthfor a known-size body, oron_read+content_length = 0for chunked. - Set appropriate timeouts.
timeout_mscovers the connection phase only; stalled streams are detected via libcurl's low-speed-time. A 60s timeout is reasonable for LLM streams. - Don't block in
on_event. The callback runs on the event loop thread. Blocking delays all other I/O. - Copy event data if needed.
xSseEventpointers are valid only during the callback.
Comparison with Other Libraries
| Feature | xhttp SSE | eventsource (JS) | sseclient-py | libcurl (manual) |
|---|---|---|---|---|
| Spec Compliance | W3C SSE | W3C SSE | W3C SSE | Manual parsing |
| Integration | xEventLoop (async) | Browser event loop | Blocking iterator | Manual |
| POST Support | Yes (DoSse) | No (GET only) | No (GET only) | Manual |
| Streaming Request Body | on_read callback | N/A | N/A | READFUNCTION |
| Cancellation | Callback return value | close() | Break loop | curl_easy_pause |
| Multi-line Data | Auto-joined with \n | Auto-joined | Auto-joined | Manual |
| Language | C99 | JavaScript | Python | C |
Key Differentiator: xhttp's SSE implementation supports POST-based SSE (via xHttpClientDoSse), which is essential for LLM API integration, with the request body streamed via on_read — no body buffering required. The incremental parser integrates seamlessly with the event loop, delivering events as they arrive without buffering the entire stream.
Implementation Details
SSE Parser State Machine
stateDiagram-v2
[*] --> Buffering: Data arrives from curl
Buffering --> ParseLine: Complete line found (\\n or \\r\\n)
ParseLine --> FieldParse: Non-empty line
ParseLine --> DispatchEvent: Empty line (event boundary)
FieldParse --> Buffering: Continue parsing
DispatchEvent --> CallUser: data field exists
DispatchEvent --> Buffering: No data (skip)
CallUser --> Buffering: User returns 0 (continue)
CallUser --> [*]: User returns non-zero (close)
SSE Field Parsing
Each non-empty line is parsed as a field:
| Line Format | Field | Value |
|---|---|---|
:comment | (ignored) | — |
event:type | event_type | "type" |
data:payload | data | "payload" (accumulated with \n) |
id:123 | id | "123" (persists across events) |
retry:5000 | retry | 5000 (ms, must be all digits) |
unknown:foo | (ignored) | — |
Multi-line data: Multiple data: lines are joined with \n:
data:line1
data:line2
data:line3
ev.data = "line1\nline2\nline3"
Data Flow
sequenceDiagram
participant Server as SSE Server
participant Curl as libcurl
participant Reader as on_read (upload)
participant Writer as sse_write_callback
participant Parser as xSseParser_
participant User as on_event / on_done
Note over Reader,Curl: POST body pulled via on_read (if set)
Reader->>Curl: fill upload buffer
Server->>Curl: HTTP 200 text/event-stream
loop For each chunk
Curl->>Writer: sse_write_callback(chunk)
Writer->>Parser: sse_parser_feed(chunk)
Parser->>Parser: Buffer + parse lines
alt Empty line (event boundary)
Parser->>User: on_event(ev)
alt User returns 0
User->>Parser: Continue
else User returns non-zero
User->>Writer: Close connection
Writer->>Curl: Return 0 (abort)
end
end
end
Curl->>User: on_done(curl_code)
SSE Request Structure
struct xSseReq_ {
struct xHttpReq_ base; /* Base request (shared with oneshot) */
xSseEventFunc on_event; /* Per-event callback */
xSseDoneFunc on_done; /* Stream-end callback */
struct xSseParser_ parser; /* SSE parser state */
struct curl_slist *sse_headers; /* Accept: text/event-stream + user headers */
};
The SSE request uses a dedicated vtable:
sse_on_done— Invokes the user'son_donecallback.sse_on_cleanup— Frees SSE-specific resources (parser, headers).
Automatic Headers
xHttpClientDoSse() automatically adds:
Accept: text/event-stream
User-provided headers are merged after this default.
TLS Deployment Guide
This guide covers end-to-end TLS deployment for xhttp, including certificate generation, server and client configuration, and mutual TLS (mTLS). For API reference, see server.md and client.md.
Prerequisites
- OpenSSL CLI — Used for certificate generation (
opensslcommand). - TLS backend compiled — libx must be built with
X_TLS_BACKEND=openssl(ormbedtls). Without a TLS backend,xHttpServerListenTls()returnsxErrno_NotSupported.
Check your build:
# If X_HAS_OPENSSL is defined, TLS is available
grep -r "X_HAS_OPENSSL" libx/x/http/
Certificate Generation
Self-Signed Certificate (Development)
For quick local development and testing:
openssl req -x509 -newkey rsa:2048 \
-keyout server-key.pem \
-out server.pem \
-days 365 -nodes \
-subj '/CN=localhost'
This produces:
server.pem— Self-signed certificateserver-key.pem— Unencrypted private key
Note: Self-signed certificates are not trusted by default. Clients must either set
skip_verify = 1or provide the certificate as a CA viaca.
CA-Signed Certificates (Production / mTLS)
For mutual TLS or production-like setups, create a private CA and sign both server and client certificates.
Step 1: Create a CA
# Generate CA private key and self-signed certificate
openssl req -x509 -newkey rsa:2048 \
-keyout ca-key.pem \
-out ca.pem \
-days 365 -nodes \
-subj '/CN=MyCA'
Step 2: Generate Server Certificate
# Generate server key + CSR
openssl req -newkey rsa:2048 \
-keyout server-key.pem \
-out server.csr \
-nodes \
-subj '/CN=localhost'
# Sign with CA
openssl x509 -req \
-in server.csr \
-CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server.pem \
-days 365
# Clean up CSR
rm server.csr
Step 3: Generate Client Certificate (for mTLS)
# Generate client key + CSR
openssl req -newkey rsa:2048 \
-keyout client-key.pem \
-out client.csr \
-nodes \
-subj '/CN=MyClient'
# Sign with the same CA
openssl x509 -req \
-in client.csr \
-CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out client.pem \
-days 365
# Clean up CSR
rm client.csr
After these steps you have:
| File | Description |
|---|---|
ca.pem | CA certificate (trusted by both sides) |
ca-key.pem | CA private key (keep secure, not deployed) |
server.pem | Server certificate (signed by CA) |
server-key.pem | Server private key |
client.pem | Client certificate (signed by CA) |
client-key.pem | Client private key |
Deployment Scenarios
1. One-Way TLS (Server Authentication Only)
The most common setup: the client verifies the server's identity, but the server does not verify the client.
sequenceDiagram
participant Client
participant Server
Client->>Server: TLS ClientHello
Server->>Client: Certificate (server.pem)
Client->>Client: Verify server cert against CA
Client->>Server: Finished
Server->>Client: Finished
Note over Client,Server: Encrypted HTTP traffic
Server:
xHttpMux mux = xHttpMuxCreate();
/* ... xHttpMuxHandle(mux, &route) ... */
xHttpServerConf sconf = {0};
sconf.resolve = xHttpMuxResolve;
sconf.router = mux;
xHttpServer server = xHttpServerCreate(&sconf);
xTlsConf tls = {
.cert = "server.pem",
.key = "server-key.pem",
};
xHttpServerListenTls(server, "0.0.0.0", 8443, &tls);
Client (with CA verification):
xTlsConf tls = {0};
tls.ca = "ca.pem";
xHttpClientConf conf = {.tls = &tls};
xHttpClient client = xHttpClientCreate(&conf);
xHttpRequestConf req = {0};
req.url = "https://localhost:8443/hello";
req.on_data = on_data;
req.on_done = on_done;
xHttpClientGet(client, &req, &resp);
Client (skip verification — development only):
xTlsConf tls = {0};
tls.skip_verify = 1;
xHttpClientConf conf = {.tls = &tls};
xHttpClient client = xHttpClientCreate(&conf);
2. Mutual TLS (mTLS)
Both sides authenticate each other. The server requires a valid client certificate signed by a trusted CA.
sequenceDiagram
participant Client
participant Server
Client->>Server: TLS ClientHello
Server->>Client: Certificate (server.pem) + CertificateRequest
Client->>Client: Verify server cert against CA
Client->>Server: Certificate (client.pem)
Server->>Server: Verify client cert against CA
Client->>Server: Finished
Server->>Client: Finished
Note over Client,Server: Mutually authenticated encrypted traffic
Server:
xTlsConf tls = {
.cert = "server.pem",
.key = "server-key.pem",
.ca = "ca.pem", /* enables client cert verification */
};
xHttpServerListenTls(server, "0.0.0.0", 8443, &tls);
Client:
xTlsConf tls = {0};
tls.ca = "ca.pem";
tls.cert = "client.pem";
tls.key = "client-key.pem";
xHttpClientConf conf = {.tls = &tls};
xHttpClient client = xHttpClientCreate(&conf);
xHttpRequestConf req = {0};
req.url = "https://localhost:8443/secure";
req.on_data = on_data;
req.on_done = on_done;
xHttpClientGet(client, &req, &resp);
3. HTTP + HTTPS on Different Ports
A single xHttpServer can serve both cleartext HTTP and HTTPS simultaneously:
xHttpServerListen(server, "0.0.0.0", 8080);
xTlsConf tls = {
.cert = "server.pem",
.key = "server-key.pem",
};
xHttpServerListenTls(server, "0.0.0.0", 8443, &tls);
Routes on the xHttpMux are shared — the same handlers serve both HTTP and HTTPS traffic.
Complete End-to-End Example
A full working example: CA-signed mTLS with server and client.
Generate Certificates
#!/bin/bash
set -e
# CA
openssl req -x509 -newkey rsa:2048 \
-keyout ca-key.pem -out ca.pem \
-days 365 -nodes -subj '/CN=TestCA'
# Server
openssl req -newkey rsa:2048 \
-keyout server-key.pem -out server.csr \
-nodes -subj '/CN=localhost'
openssl x509 -req -in server.csr \
-CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server.pem -days 365
rm server.csr
# Client
openssl req -newkey rsa:2048 \
-keyout client-key.pem -out client.csr \
-nodes -subj '/CN=MyClient'
openssl x509 -req -in client.csr \
-CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out client.pem -days 365
rm client.csr
echo "Generated: ca.pem, server.pem, server-key.pem, client.pem, client-key.pem"
Server Code
#include <stdio.h>
#include <string.h>
#include <x/base/event.h>
#include <x/http/server.h>
static int on_secure(xHttpCtx *ctx, void *arg) {
(void)arg;
xHttpCtxSetHeader(ctx, "Content-Type", "text/plain");
xHttpCtxSend(ctx, "mTLS OK!\n", 9);
return 0;
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xHttpMux mux = xHttpMuxCreate();
xHttpRouteConf route = {
.pattern = "GET /secure",
.on_request = on_secure,
};
xHttpMuxHandle(mux, &route);
xHttpServerConf sconf = {0};
sconf.resolve = xHttpMuxResolve;
sconf.router = mux;
xHttpServer server = xHttpServerCreate(&sconf);
xTlsConf tls = {
.cert = "server.pem",
.key = "server-key.pem",
.ca = "ca.pem",
};
xHttpServerListenTls(server, "0.0.0.0", 8443, &tls);
printf("mTLS server listening on :8443\n");
xEventLoopRun(loop);
xHttpServerDestroy(server);
xHttpMuxDestroy(mux);
xEventLoopDestroy(loop);
return 0;
}
Client Code
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <x/base/event.h>
#include <x/http/client.h>
struct Resp { long status; char *buf; size_t len; };
static int on_data(const char *data, size_t len, void *arg) {
struct Resp *r = arg;
r->buf = realloc(r->buf, r->len + len + 1);
memcpy(r->buf + r->len, data, len);
r->len += len;
r->buf[r->len] = '\0';
return 0;
}
static void on_done(xHttpCtx *ctx, void *arg) {
struct Resp *r = arg;
r->status = ctx->status_code;
if (ctx->curl_code != 0)
printf("TLS error: %s\n", ctx->curl_error ? ctx->curl_error : "?");
else
printf("HTTP %ld: %s\n", r->status, r->buf ? r->buf : "(empty)");
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xTlsConf tls = {0};
tls.ca = "ca.pem";
tls.cert = "client.pem";
tls.key = "client-key.pem";
xHttpClientConf conf = {.tls = &tls};
xHttpClient client = xHttpClientCreate(&conf);
xHttpRequestConf req = {0};
req.url = "https://localhost:8443/secure";
req.on_data = on_data;
req.on_done = on_done;
struct Resp r = {0};
xHttpClientGet(client, &req, &r);
xEventLoopRun(loop);
free(r.buf);
xHttpClientDestroy(client);
xEventLoopDestroy(loop);
return 0;
}
Verify with curl
# One-way TLS (skip verify)
curl -k https://localhost:8443/secure
# One-way TLS (with CA)
curl --cacert ca.pem https://localhost:8443/secure
# mTLS
curl --cacert ca.pem \
--cert client.pem \
--key client-key.pem \
https://localhost:8443/secure
skip_verify Behavior
| Value | Behavior |
|---|---|
0 (default) | Peer verification enabled. Server verifies client cert (if ca is set); client verifies server cert. |
| non-zero | All peer verification disabled. Development only. |
ALPN and HTTP/2 over TLS
When TLS is enabled, ALPN (Application-Layer Protocol Negotiation) automatically selects the HTTP protocol:
- If the client supports HTTP/2, ALPN negotiates
h2and the connection uses HTTP/2 framing. - Otherwise, ALPN falls back to
http/1.1.
This is transparent to application code — the same xHttpMux routes and route callbacks work regardless of the negotiated protocol.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
xErrno_NotSupported from ListenTls | No TLS backend compiled | Rebuild with X_TLS_BACKEND=openssl |
Client gets curl_code != 0, status_code == 0 | TLS handshake failed | Check cert paths, CA trust, and skip_verify settings |
| Self-signed cert rejected | Client verifies against system CA bundle | Set ca to the self-signed cert, or use skip_verify = 1 for dev |
| mTLS handshake fails | Client didn't provide cert, or cert not signed by server's ca | Ensure client cert is signed by the same CA specified in server's ca |
| "wrong CA path" error | ca points to non-existent file | Verify the file path exists and is readable |
Connection works with skip_verify but not without | Server cert CN doesn't match hostname, or CA not trusted | Use ca pointing to the signing CA, ensure CN matches the hostname |
Security Best Practices
- Never use
skip_verifyin production. It disables all certificate validation, making the connection vulnerable to MITM attacks. - Keep private keys secure.
ca-key.pem,server-key.pem, andclient-key.pemshould have restricted file permissions (chmod 600). - Use short-lived certificates. Set reasonable expiry (
-days) and rotate certificates before they expire. - For mTLS, set
caon the server side. Verification is enabled by default (skip_verify = 0), so the server will require a valid client certificate whencais set. - Don't deploy the CA private key. Only
ca.pem(the public certificate) needs to be distributed. Keepca-key.pemoffline or in a secure vault. - Match CN/SAN to hostname. The server certificate's Common Name (or Subject Alternative Name) should match the hostname clients use to connect.
API Quick Reference
Server Side
| Item | Description |
|---|---|
xTlsConf | Struct: cert, key, ca, key_password, skip_verify |
xHttpServerConf | Struct: resolve, router, idle_timeout_ms, max_header_size |
xHttpServerCreate(&sconf) | Create server with resolver + limits |
xHttpServerListenTls(server, host, port, &tls) | Start HTTPS listener |
Client Side
| Item | Description |
|---|---|
xTlsConf | Struct: ca, cert, key, key_password, skip_verify |
xHttpClientConf | Struct: tls (pointer to xTlsConf), http_version |
xHttpClientCreate(&conf) | Create client with TLS config |
xHttpRequestConf | Per-request config: url, method, headers, on_read, on_data, on_done |
WebSocket Client Side
| Item | Description |
|---|---|
xTlsConf | Struct: ca, cert, key, key_password, skip_verify |
xTlsCtx | Opaque shared TLS context from xTlsCtxCreate() |
xWsConnectConf | Struct: tls (pointer to xTlsConf), tls_ctx (shared context, priority over tls) |
xWsConnect(&conf, &cbs, arg) | Initiate async WebSocket connection with optional TLS |
For full API details, see server.md and client.md.
dns.h — Async DNS Client + Server
Introduction
dns.h is libx's DNS module, providing both a client (resolver) and server (authoritative/forwarding) built directly on the DNS protocol (RFC 1035, RFC 6891) over UDP. Unlike libx/x/net/dns.c which offloads getaddrinfo() to a thread pool, dns.h implements the protocol directly — truly async, no thread pool, no blocking calls. All I/O is driven by xbase's event loop.
Key features:
- Truly async — DNS queries over UDP, no
getaddrinfo, no thread pool - Bitmask queries —
xDnsType_A | xDnsType_AAAAsends parallel queries, merges results - TTL caching — Automatic cache with record-level TTL enforcement
- Server with zones — Authoritative records, upstream forwarding, and query filtering
- RFC compliant — RFC 1034, RFC 1035, RFC 3596, RFC 6891 (EDNS0)
Design Philosophy
-
Protocol-Native — xdns builds and parses DNS wire-format packets directly. No
getaddrinfo(), no/etc/hosts, no external resolver libraries. Full control over every byte on the wire. -
Truly Async — A single UDP socket per client, registered with xbase's event loop. Queries are multiplexed by 16-bit transaction ID. No threads, no blocking calls, no polling.
-
Double-Packed — Multi-type queries (
A | AAAA) are packed into a singlexDnsClientDo()call. The client sends one UDP packet per type and merges results, invoking the callback once. -
Composable — The server can be purely authoritative, purely forwarding, or a hybrid. Filter callbacks allow ad-blocking and custom DNS logic without modifying the core.
-
Minimal Dependencies — Depends only on
xbase(event loop, socket, timer, map). No external DNS libraries.
Architecture
graph TD
subgraph "Client"
CLIENT["xDnsClient"]
SOCKET["UDP Socket"]
TIMER["Timeout Timer"]
CACHE["TTL Cache"]
NSTABLE["Nameserver Table"]
end
subgraph "Server"
SERVER["xDnsServer"]
LISTENER["UDP Listener"]
ZONES["Zone Records"]
FILTER["Filter Callback"]
FORWARDER["xDnsClient (upstream)"]
end
APP["Application"] --> CLIENT
APP --> SERVER
CLIENT --> SOCKET --> LOOP["xEventLoop"]
CLIENT --> TIMER --> LOOP
CLIENT --> CACHE
CLIENT --> NSTABLE
SERVER --> LISTENER --> LOOP
SERVER --> ZONES
SERVER --> FILTER
SERVER --> FORWARDER --> CLIENT
style CLIENT fill:#4a90d9,color:#fff
style SERVER fill:#4a90d9,color:#fff
style LOOP fill:#50b86c,color:#fff
style CACHE fill:#f5a623,color:#fff
API Reference
Client
| Function | Description |
|---|---|
xDnsClientCreate(conf) | Create a client bound to the current event loop. conf may be NULL. |
xDnsClientDestroy(client) | Destroy client, cancel in-flight queries. Safe with NULL. |
xDnsClientDo(client, name, type, cb, arg) | Resolve a hostname. type is a bitmask of xDnsType values. |
Server
| Function | Description |
|---|---|
xDnsServerCreate(conf) | Create server (authoritative, forwarding, or hybrid). conf may be NULL. |
xDnsServerDestroy(server) | Destroy server. Zones are NOT freed. Safe with NULL. |
xDnsServerListen(server, host, port) | Start listening on a UDP port. |
xDnsServerPort(server) | Return the actual bound port. |
xDnsServerAddZone(server, zone) | Attach a zone. Checked in registration order. |
Zone
| Function | Description |
|---|---|
xDnsZoneCreate() | Create an empty zone. |
xDnsZoneDestroy(zone) | Destroy a zone and free all records. Safe with NULL. |
xDnsZoneAdd(zone, name, type, rdata, rdlen, ttl) | Add a record. name and rdata are copied. |
Types
| Type | Description |
|---|---|
xDnsType | Bitmask enum: xDnsType_A (1<<0), xDnsType_AAAA (1<<1), xDnsType_CNAME (1<<2) |
xDnsRecord | Singly-linked list node: qtype, ttl, name, rdata, rdlength, next |
xDnsClientConf | Client config: nameservers[8], timeout_ms, retries, enable_cache |
xDnsServerConf | Server config: forwarder, filter, filter_arg, cache_enabled |
xDnsCallback | void (*)(xErrno err, const xDnsRecord *records, void *arg) |
xDnsFilterFunc | int (*)(const char *name, uint16_t type, void *arg) — 0=allow, non-zero=block |
Usage Examples
Basic A + AAAA query
#include <x/base/event.h>
#include <x/dns/dns.h>
static void on_resolved(xErrno err, const xDnsRecord *records, void *arg) {
if (err != xErrno_Ok) return;
for (const xDnsRecord *r = records; r; r = r->next) {
char ip[INET6_ADDRSTRLEN];
int af = (r->qtype == 28) ? AF_INET6 : AF_INET;
inet_ntop(af, r->rdata, ip, sizeof(ip));
printf("[%u] %s\n", r->qtype, ip);
}
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xDnsClientConf conf = {0};
conf.nameservers[0] = "8.8.8.8";
conf.timeout_ms = 5000;
xDnsClient client = xDnsClientCreate(&conf);
xDnsClientDo(client, "example.com", xDnsType_A | xDnsType_AAAA,
on_resolved, NULL);
xEventLoopRun(loop);
xDnsClientDestroy(client);
xEventLoopDestroy(loop);
return 0;
}
Best Practices
- One client per event loop — A single
xDnsClientmultiplexes all queries over one UDP socket. - Copy data in callbacks —
xDnsRecordpointers are valid only during the callback. - Create forwarder before server — The
xDnsClientpassed viaxDnsServerConf.forwardermust outlive the server. - Free zones separately —
xDnsServerDestroy()does not free zones. - Handle partial success —
A | AAAAmay succeed for A and time out for AAAA.
Implementation Details
Query Flow
xDnsClientDo("example.com", A | AAAA)
│
├─ Cache check? ─── hit → invoke callback immediately
│
├─ Send A query → UDP socket → first nameserver
├─ Send AAAA query → UDP socket → first nameserver
│
├─ Wait: event loop drives readable UDP socket
│ ├─ DNS response → parse → store in query table
│ └─ Timeout → retry with next nameserver
│
└─ All queries done (or timed out) → invoke callback once
Packet Format
xdns builds DNS query packets with:
- 12-byte header (ID, flags, QDCOUNT=1, ANCOUNT=0, NSCOUNT=0, ARCOUNT=1)
- Question section (QNAME, QTYPE, QCLASS=IN)
- EDNS0 OPT record (RFC 6891) advertising UDP payload size 4096
Response parsing handles DNS name compression pointers (RFC 1035 §4.1.4).
Server Processing
UDP query received
├─ Parse query packet
├─ Filter callback (if set) → block? → NXDOMAIN
├─ Check zones (registration order) → hit? → authoritative response
└─ Forwarder? → xDnsClientDo → build response from upstream result
Relationship with Other Modules
- xbase — Depends on
xEventLoopfor async I/O,xSocket/xSocketSendTo/xSocketRecvFromfor UDP,xTimerfor query timeouts, andxMapfor the query table and TTL cache.
Sub-pages
- Client API — Resolver with caching and bitmask queries
- Server API — Authoritative zones, forwarding, and filtering
dns.h — DNS Client
Introduction
xDnsClient is a truly asynchronous DNS resolver. It implements the DNS protocol (RFC 1035) directly over UDP — no getaddrinfo(), no thread pool, no blocking calls. A single non-blocking UDP socket is registered with xbase's event loop, and concurrent queries are multiplexed by 16-bit transaction ID.
The client supports A, AAAA, and CNAME record types, EDNS0 OPT records (RFC 6891) advertising a 4096-byte UDP payload size, and DNS name compression (RFC 1035 §4.1.4) in the response parser.
Design Philosophy
-
One Socket, Many Queries — A single UDP socket per client, not one per query. The 16-bit transaction ID in the DNS header multiplexes outstanding queries.
-
Fire-and-Forget —
xDnsClientDo()queues the query and returns immediately. The callback fires on the event loop thread when results arrive (or the query times out). -
Cache-First — When caching is enabled, a cache hit invokes the callback immediately via a zero-timer, avoiding network I/O entirely.
-
Retry with Rotation — On timeout, the next nameserver in the configured list is tried. Each nameserver gets up to
retriesattempts before moving on. -
Merge, Don't Serialize —
xDnsType_A | xDnsType_AAAAsends parallel queries. The callback fires once with all results merged into a single linked list.
Architecture
graph TD
DO["xDnsClientDo(name, type, cb, arg)"]
CACHE["Check TTL Cache"]
HIT["Hit → invoke cb immediately"]
QUERY["Build DNS packet(s)"]
SEND["xSocketSendTo() → nameserver:53"]
TABLE["Insert into query table (ID → state)"]
TIMER["Start timeout timer"]
IO["Event loop: UDP socket readable"]
PARSE["Parse DNS response → match ID"]
MERGE["Accumulate records"]
DONE["All sub-queries complete → invoke cb"]
DO --> CACHE
CACHE -->|hit| HIT
CACHE -->|miss| QUERY
QUERY --> SEND --> TABLE --> TIMER
TIMER -->|timeout| RETRY["Next nameserver"] --> SEND
IO --> PARSE --> MERGE -->|pending| IO
MERGE -->|all done| DONE
style CACHE fill:#f5a623,color:#fff
style DONE fill:#50b86c,color:#fff
style SEND fill:#4a90d9,color:#fff
API Reference
Lifecycle
| Function | Signature | Description |
|---|---|---|
xDnsClientCreate | xDnsClient xDnsClientCreate(const xDnsClientConf *conf) | Create a client bound to the current event loop. conf may be NULL for defaults. |
xDnsClientDestroy | void xDnsClientDestroy(xDnsClient client) | Destroy client. In-flight queries are cancelled; callbacks NOT invoked. Safe with NULL. |
xDnsClientDo | xErrno xDnsClientDo(xDnsClient client, const char *name, xDnsType type, xDnsCallback cb, void *arg) | Resolve a hostname asynchronously. |
Types
xDnsClientConf
XDEF_STRUCT(xDnsClientConf) {
const char *nameservers[8]; // Up to 8 nameservers (NULL-terminated). NULL[0] = auto-detect.
int timeout_ms; // Per-query timeout. Default 5000 ms.
int retries; // Retries with next nameserver. Default 2.
int enable_cache; // 1 = enable TTL cache. Default 1.
};
Nameserver strings support optional port: "8.8.8.8", "8.8.8.8:53", "127.0.0.1:5353". Auto-detection reads /etc/resolv.conf on POSIX or uses GetNetworkParams() on Windows, falling back to 8.8.8.8.
xDnsCallback
typedef void (*xDnsCallback)(xErrno err, const xDnsRecord *records, void *arg);
err—xErrno_Okon success (including partial results).xErrno_Timeouton total timeout.xErrno_DnsNotFoundon NXDOMAIN.records— Linked list ofxDnsRecord, or NULL on error. Valid only during the callback.arg— User argument fromxDnsClientDo().
xDnsRecord
XDEF_STRUCT(xDnsRecord) {
uint16_t qtype; // DNS QTYPE: 1=A, 28=AAAA, 5=CNAME
uint32_t ttl; // TTL in seconds
const char *name; // Owner name (NUL-terminated, lowercase)
const void *rdata; // Raw RDATA (A: 4 bytes, AAAA: 16 bytes, CNAME: NUL-terminated domain)
size_t rdlength; // Length of rdata in bytes
xDnsRecord *next; // Next record in the list, or NULL
};
Usage Examples
Basic A record resolution
void on_resolve(xErrno err, const xDnsRecord *records, void *arg) {
if (err != xErrno_Ok) return;
for (const xDnsRecord *r = records; r; r = r->next) {
if (r->qtype == 1) {
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, r->rdata, ip, sizeof(ip));
printf("A: %s (TTL=%u)\n", ip, r->ttl);
}
}
}
xDnsClientConf conf = {0};
conf.nameservers[0] = "8.8.8.8";
xDnsClient client = xDnsClientCreate(&conf);
xDnsClientDo(client, "example.com", xDnsType_A, on_resolve, NULL);
Parallel A + AAAA
xDnsClientDo(client, "google.com", xDnsType_A | xDnsType_AAAA, on_resolve, NULL);
// Callback receives both A and AAAA records merged into one list.
Auto-discover nameservers
xDnsClientConf conf = {0}; // nameservers[0] is NULL → auto-detect
xDnsClient client = xDnsClientCreate(&conf);
// Reads /etc/resolv.conf on Linux/macOS, GetNetworkParams() on Windows.
TTL caching
xDnsClientConf conf = {0};
conf.nameservers[0] = "8.8.8.8";
conf.enable_cache = 1;
xDnsClient client = xDnsClientCreate(&conf);
xDnsClientDo(client, "example.com", xDnsType_A, on_first, NULL);
// ... later ...
xDnsClientDo(client, "example.com", xDnsType_A, on_second, NULL);
// on_second fires immediately with cached result (within TTL).
Best Practices
- One client per event loop — A single client multiplexes all queries over one UDP socket.
- Copy records in callbacks —
xDnsRecordpointers are library-owned and freed after the callback returns. - Check error codes —
xErrno_Okmeans success (possibly with 0 records for NODATA).xErrno_Timeoutmeans all nameservers were tried and none responded. - Handle partial success —
A | AAAAmay partially succeed. Check each record'sqtypeindividually. - Initiate queries before running the loop —
xDnsClientDo()must be called from the event loop thread beforexEventLoopRun().
Comparison with Other Libraries
| Feature | xdns client | getaddrinfo + thread pool | c-ares |
|---|---|---|---|
| Async Model | Event-loop native | Thread-pool wrapper | Event-loop native |
| No Threads | Yes | No | Yes |
| Protocol-Native | Yes (builds DNS packets) | No (OS resolver) | Yes |
| Bitmask Queries | Yes (A | AAAA) | No | No (separate calls) |
| TTL Cache | Built-in | Varies by OS | Via ares_library_init |
| EDNS0 | RFC 6891 (4096-byte UDP) | OS-dependent | Yes |
| Dependencies | xbase only | POSIX threads | libcares |
| Language | C99 | C | C |
Implementation Details
Query Lifecycle
xDnsClientDo("example.com", A | AAAA)
│
├─ Cache hit (both types)? → enqueue callback via zero-timer → return
│
├─ For each type bit (A, AAAA):
│ ├─ Build query packet (header + question + EDNS0 OPT)
│ ├─ xSocketSendTo(nameserver, 53, packet)
│ ├─ Insert entry into query table (maps ID → state)
│ └─ Start timeout timer (timeout_ms)
│
└─ Event loop dispatches:
├─ UDP socket readable → dns_parse() → match ID → store result
│ └─ All sub-queries done → merge → invoke callback
└─ Timer fires → retry next nameserver (or fail all sub-queries)
Packet Structure
Query packet:
┌─────────── 12 bytes ───────────┬──── variable ────┬─── 11 bytes ───┐
│ Header (ID, flags, QDCOUNT=1…) │ Question section │ EDNS0 OPT RR │
└────────────────────────────────┴──────────────────┴─────────────────┘
The EDNS0 OPT record (RFC 6891):
- UDP payload size: 4096 bytes
- Extended RCODE: 0
- Version: 0
- DO bit: not set
Name Compression
Response parsing handles DNS name compression (RFC 1035 §4.1.4): two high bits of a length octet set to 11 indicate a 14-bit pointer to another location in the message. The parser follows these pointers to reconstruct the full domain name.
ID Multiplexing
The client uses a hash map keyed by 16-bit transaction ID. Each entry stores:
- Query name (for matching)
- Callback + arg
- Pending sub-query count (for multi-type queries)
- Accumulated record list
- Timeout timer reference
Nameserver Rotation
On timeout, the client advances to the next nameserver in the configured list. If the last nameserver is reached, it wraps back to the first and increment the retry counter. When retries are exhausted, the query fails with xErrno_Timeout.
dns.h — DNS Server
Introduction
xDnsServer is a DNS server that listens on a UDP port and responds to queries using the DNS wire protocol. It supports three modes that can coexist:
- Authoritative — Serve records from local zones (
xDnsZone) - Forwarding — Forward unresolved queries to an upstream
xDnsClient - Filtered — Intercept, block, or rewrite queries via a filter callback before processing
The server processes queries on the event loop thread. Zone lookups are case-insensitive and checked in registration order (first match wins). When both a zone match and a forwarder are present, the zone takes priority.
Design Philosophy
-
Authoritative-First — Zone records are checked before forwarding. A zone match for the query name + type returns an authoritative response immediately, bypassing the upstream resolver.
-
Composable Filtering — The filter callback runs before zone lookup and forwarding, allowing ad-blocking, access control, or custom DNS logic without modifying the core.
-
Stateless Responses — Each query is self-contained. The server does not maintain connection state — it parses, processes, and responds in a single callback.
-
Cache Sharing — When
cache_enabledis set, the server's forwarder caches upstream results. Subsequent identical queries are resolved from cache without additional upstream I/O.
Architecture
graph TD
LISTEN["UDP Listener (port 53)"]
PARSE["Parse Query"]
FILTER["Filter Callback"]
BLOCK["Block → NXDOMAIN"]
ZONES["Check Zones"]
ZONE_HIT["Hit → Build Response"]
FORWARD["Forward to xDnsClient"]
UPSTREAM["Upstream Resolution"]
RESPONSE["Send Response via sendto"]
LISTEN --> PARSE
PARSE --> FILTER
FILTER -->|block| BLOCK
FILTER -->|allow| ZONES
ZONES -->|hit| ZONE_HIT
ZONES -->|miss| FORWARD
FORWARD --> UPSTREAM
UPSTREAM --> RESPONSE
ZONE_HIT --> RESPONSE
BLOCK --> RESPONSE
style RESPONSE fill:#50b86c,color:#fff
style BLOCK fill:#e74c3c,color:#fff
style PARSE fill:#4a90d9,color:#fff
API Reference
Lifecycle
| Function | Signature | Description |
|---|---|---|
xDnsServerCreate | xDnsServer xDnsServerCreate(const xDnsServerConf *conf) | Create server. conf may be NULL for authoritative-only with no filter. |
xDnsServerDestroy | void xDnsServerDestroy(xDnsServer server) | Destroy server and close listener. Zones are NOT freed. Safe with NULL. |
xDnsServerListen | xErrno xDnsServerListen(xDnsServer server, const char *host, uint16_t port) | Start listening on a UDP port. host may be NULL for 0.0.0.0. |
xDnsServerPort | uint16_t xDnsServerPort(xDnsServer server) | Return the actual bound port (useful when port was 0). |
xDnsServerAddZone | xErrno xDnsServerAddZone(xDnsServer server, xDnsZone zone) | Attach a zone. Zones checked in registration order. |
Zone
| Function | Signature | Description |
|---|---|---|
xDnsZoneCreate | xDnsZone xDnsZoneCreate(void) | Create an empty zone. |
xDnsZoneDestroy | void xDnsZoneDestroy(xDnsZone zone) | Destroy zone and free all records. Safe with NULL. |
xDnsZoneAdd | xErrno xDnsZoneAdd(xDnsZone zone, const char *name, xDnsType type, const void *rdata, size_t rdlen, uint32_t ttl) | Add a record. name and rdata are copied. type must be exactly one bit. |
Types
xDnsServerConf
XDEF_STRUCT(xDnsServerConf) {
xDnsClient forwarder; // Upstream resolver. NULL = authoritative-only.
xDnsFilterFunc filter; // Query filter callback. NULL = no filter.
void *filter_arg; // Argument forwarded to filter.
int cache_enabled; // 1 = share cache with forwarder. Default 0.
};
xDnsFilterFunc
typedef int (*xDnsFilterFunc)(const char *name, uint16_t type, void *arg);
// Return 0 = allow normal processing. Non-zero = block (responds NXDOMAIN).
name is NUL-terminated and lowercased. type is the DNS QTYPE (1=A, 28=AAAA, etc.).
Usage Examples
Authoritative server
xDnsZone zone = xDnsZoneCreate();
uint8_t ip[4] = {192, 168, 1, 100};
xDnsZoneAdd(zone, "myapp.local", xDnsType_A, ip, 4, 3600);
xDnsServerConf conf = {0};
xDnsServer server = xDnsServerCreate(&conf);
xDnsServerAddZone(server, zone);
xDnsServerListen(server, "0.0.0.0", 5353);
// "myapp.local" → 192.168.1.100. Anything else → NXDOMAIN.
Forwarding server (local DNS relay)
xDnsClientConf cconf = {0};
cconf.nameservers[0] = "8.8.8.8";
xDnsClient upstream = xDnsClientCreate(&cconf);
xDnsServerConf sconf = {0};
sconf.forwarder = upstream;
sconf.cache_enabled = 1;
xDnsServer server = xDnsServerCreate(&sconf);
xDnsServerListen(server, "0.0.0.0", 53);
// All queries forwarded to 8.8.8.8, cached, returned.
Authoritative + forwarding (hybrid)
xDnsZone zone = xDnsZoneCreate();
uint8_t local_ip[4] = {10, 0, 0, 1};
xDnsZoneAdd(zone, "internal.corp", xDnsType_A, local_ip, 4, 3600);
xDnsClient upstream = xDnsClientCreate(&(xDnsClientConf){
.nameservers = {"8.8.8.8"} });
xDnsServerConf conf = {0};
conf.forwarder = upstream;
xDnsServer server = xDnsServerCreate(&conf);
xDnsServerAddZone(server, zone);
xDnsServerListen(server, "0.0.0.0", 53);
// "internal.corp" → 10.0.0.1 (zone). "google.com" → forwarded.
DNS filter
int ad_filter(const char *name, uint16_t type, void *arg) {
if (strstr(name, "ads.") || strstr(name, "tracker.")) return 1;
return 0;
}
xDnsClient upstream = xDnsClientCreate(&(xDnsClientConf){
.nameservers = {"8.8.8.8"} });
xDnsServerConf conf = {0};
conf.forwarder = upstream;
conf.filter = ad_filter;
xDnsServer server = xDnsServerCreate(&conf);
xDnsServerListen(server, "0.0.0.0", 53);
// "ads.tracker.com" → NXDOMAIN. "example.com" → forwarded.
Round-robin zones
uint8_t ip1[4] = {10, 0, 0, 1};
uint8_t ip2[4] = {10, 0, 0, 2};
xDnsZoneAdd(zone, "api.local", xDnsType_A, ip1, 4, 300);
xDnsZoneAdd(zone, "api.local", xDnsType_A, ip2, 4, 300);
// Query for "api.local" → both IPs in answer section.
Best Practices
- Create forwarder before server — The
xDnsClientpassed viaxDnsServerConf.forwardermust outlive the server. - Free zones separately —
xDnsServerDestroy()does not free zones. CallxDnsZoneDestroy()manually. - Zone order matters — Zones are checked in registration order. First match wins.
- Case-insensitive matching —
"MyApp.local"and"myapp.local"match the same zone entry. - Filter returns 0 to allow — Non-zero return drops the query with NXDOMAIN.
Implementation Details
Query Processing
UDP query received (event loop callback)
│
├─ dns_parse(packet, len) → extract name, type, class, ID
│
├─ filter callback (if set)
│ └─ returns non-zero → build NXDOMAIN response → send → done
│
├─ zone lookup (case-insensitive, registration order)
│ └─ found → build authoritative response with zone records → send → done
│
├─ forwarder configured?
│ ├─ Yes → xDnsClientDo(forwarder, name, type, forward_cb, response_ctx)
│ │ forward_cb builds response from upstream records → send
│ └─ No → build NXDOMAIN response → send
│
└─ xSocketSendTo(client_addr, response_packet, len)
Response Building
For zone hits, the server constructs a DNS response packet with:
- Header: same ID as query, QR=1 (response), AA=1 (authoritative)
- Question section: echoed from query
- Answer section: matching zone records (formatted as DNS RRs)
- EDNS0 OPT record (if query had one)
For forwarding, the upstream xDnsClient resolves the query, and the server builds a response from the returned xDnsRecord list, preserving the original query ID.
Memory Ownership
- Query packets — Owned by the caller (stack or heap). The server does not copy or retain them.
- Response packets — Built into a stack-allocated buffer (max 4096 bytes for UDP DNS).
- Zone records —
xDnsZoneAdd()copies bothnameandrdata. The caller retains ownership of the original data.
xp2p — P2P Connectivity & WebRTC DataChannel
Introduction
xp2p is libx's peer-to-peer connectivity module, providing a lightweight WebRTC DataChannel stack in pure C99. It implements the full protocol pipeline — ICE (NAT traversal) → DTLS (encryption) → SCTP (reliable/unreliable transport) → DataChannel (messaging) — orchestrated by a top-level xPeerConnection API that mirrors the browser RTCPeerConnection.
At the lower level, xp2p includes a complete STUN/TURN client stack, SDP encoding/decoding, and an event-driven ICE agent that handles candidate gathering, connectivity checks, and nomination. At the higher level, xPeerConnection manages SDP offer/answer negotiation, DTLS 1.2 handshake with self-signed ECDSA certificates, user-space SCTP association (via usrsctp), and the DataChannel Establishment Protocol (DCEP, RFC 8832).
Design Philosophy
-
Single-Threaded, Event-Driven — The entire stack (ICE, DTLS, SCTP, DataChannel) runs on the libx event loop. All callbacks are invoked on the event loop thread, keeping the async programming model consistent with the rest of libx.
-
RFC Compliance — Implements ICE (RFC 8445), STUN (RFC 5389), TURN (RFC 5766), DTLS 1.2 (RFC 6347), SCTP (RFC 4960), and DataChannel (DCEP, RFC 8832) with proper message integrity, fingerprint, and retransmission.
-
Pluggable DTLS Backend — The DTLS layer supports both OpenSSL and mbedTLS at compile time, making xp2p suitable for both server and embedded environments. The ICE layer's built-in crypto (MD5, SHA-1, HMAC-SHA1, CRC-32) requires no external libraries.
-
Layered Architecture — The module is cleanly layered: STUN message codec → STUN transaction manager → TURN client → ICE agent → DTLS transport → SCTP transport → DataChannel. Each layer can be used independently, or composed via
xPeerConnectionfor the full WebRTC experience. -
Minimal Footprint — Unlike full WebRTC implementations (libwebrtc ~50 MiB), xp2p focuses exclusively on DataChannel connectivity with a shared library size of ~200 KiB.
Architecture
High-Level: PeerConnection Stack
graph TD
subgraph "Application"
APP["User Application"]
end
subgraph "xPeerConnection"
PC["xPeerConnection<br/>peer_connection.h"]
DC["xDataChannelMgr / xDataChannel<br/>datachannel.h"]
SCTP["xSctpTransport<br/>sctp_transport.h"]
DTLS["xDtlsTransport<br/>dtls_transport.h"]
ICE["xIceAgent<br/>ice_agent.h"]
end
subgraph "xbase"
EV["xEventLoop<br/>event.h"]
end
APP --> PC
PC --> DC
DC --> SCTP
SCTP --> DTLS
DTLS --> ICE
ICE --> EV
style PC fill:#4a90d9,color:#fff
style DC fill:#50b86c,color:#fff
style SCTP fill:#f5a623,color:#fff
style DTLS fill:#e74c3c,color:#fff
style ICE fill:#9b59b6,color:#fff
Protocol Stack
┌─────────────────────────────┐
│ DataChannel (DCEP) │ RFC 8832 — message framing
├─────────────────────────────┤
│ SCTP (usrsctp) │ RFC 4960 — reliable/unreliable streams
├─────────────────────────────┤
│ DTLS 1.2 │ RFC 6347 — encryption
├─────────────────────────────┤
│ ICE (STUN/TURN) │ RFC 8445 — NAT traversal
├─────────────────────────────┤
│ UDP │
└─────────────────────────────┘
Low-Level: ICE Internals
graph TD
subgraph "ICE Layer"
ICE["xIceAgent<br/>ice_agent.h"]
SDP["xIceSdp<br/>SDP Codec<br/>sdp.h"]
TURN["xTurnClient<br/>TURN Client<br/>turn_client.h"]
CHAN["xTurnChannel<br/>ChannelData Framing<br/>turn_channel.h"]
TXN["xStunTxnMgr<br/>Transaction Manager<br/>stun_txn.h"]
MSG["xStunMsg<br/>Message Codec<br/>stun_msg.h"]
ATTR["xStunAttrWriter / xStunAttrIter<br/>Attribute Codec<br/>stun_attr.h"]
CAND["xIceCandidate / xIcePair<br/>Candidate & Pair<br/>ice_candidate.h / ice_pair.h"]
CRYPTO["xIceHmacSHA1 / xIceCrc32<br/>Crypto Helpers<br/>ice_crypto.h"]
end
subgraph "xbase / xnet"
EV["xEventLoop<br/>event.h"]
SOCK["xSocket<br/>socket.h"]
end
ICE --> SDP
ICE --> TURN
ICE --> TXN
ICE --> CAND
TURN --> TXN
TURN --> CHAN
TXN --> MSG
TXN --> ATTR
MSG --> CRYPTO
ATTR --> CRYPTO
ICE --> EV
ICE --> SOCK
TXN --> EV
style ICE fill:#50b86c,color:#fff
style SDP fill:#4a90d9,color:#fff
style TURN fill:#e74c3c,color:#fff
style TXN fill:#f5a623,color:#fff
style MSG fill:#9b59b6,color:#fff
style ATTR fill:#9b59b6,color:#fff
Sub-Module Overview
| Header | Component | Description | Doc |
|---|---|---|---|
peer_connection.h | xPeerConnection | WebRTC PeerConnection — orchestrates ICE + DTLS + SCTP + DataChannel | pc.md |
datachannel.h | xDataChannel / xDataChannelMgr | WebRTC DataChannel (DCEP, RFC 8832) over SCTP streams | pc.md |
dtls_transport.h | xDtlsTransport | DTLS 1.2 transport with backend-agnostic design (OpenSSL / mbedTLS) | pc.md |
sctp_transport.h | xSctpTransport | SCTP over DTLS via usrsctp for WebRTC DataChannel | pc.md |
ice_agent.h | xIceAgent | Full ICE agent — gathering, checks, nomination, data send/recv | ice.md |
ice_candidate.h | xIceCandidate | Candidate representation and priority calculation (RFC 8445 §5.1.2.1) | — |
ice_pair.h | xIcePair | Candidate pair priority and sorting (RFC 8445 §6.1.2.3) | — |
sdp.h | xIceSdp | SDP offer/answer encoding and decoding (RFC 4566) | — |
stun_msg.h | xStunMsg | STUN message header encoding/decoding (RFC 5389) | — |
stun_attr.h | xStunAttrWriter / xStunAttrIter | STUN attribute encoding/decoding with integrity and fingerprint | — |
stun_txn.h | xStunTxnMgr | STUN transaction manager with exponential-backoff retransmission | — |
turn_client.h | xTurnClient | TURN allocation, permissions, channel bindings, and relay data (RFC 5766) | — |
turn_channel.h | xTurnChannel | TURN ChannelData framing (RFC 5766 §11) | — |
ice_crypto.h | xIceHmacSHA1 / xIceCrc32 | Built-in HMAC-SHA1, SHA-1, MD5, CRC-32 | — |
Quick Start
PeerConnection (Recommended)
The xPeerConnection API is the recommended entry point for most applications. It orchestrates the full ICE → DTLS → SCTP → DataChannel pipeline:
#include <x/base/event.h>
#include <x/p2p/peer_connection.h>
#include <stdio.h>
#include <string.h>
static void on_state(xPeerConnection pc, xPeerConnectionState state, void *arg) {
printf("PeerConnection state: %d\n", state);
}
static void on_dc_open(xDataChannel channel, void *arg) {
printf("DataChannel open: %s\n", xDataChannelGetLabel(channel));
const char *msg = "Hello DataChannel!";
xDataChannelSendString(channel, msg, strlen(msg));
}
static void on_dc_message(xDataChannel channel, xDataChannelMsgType type,
const uint8_t *data, size_t len, void *arg) {
printf("Received: %.*s\n", (int)len, (const char *)data);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xPeerConnectionConf conf = {0};
conf.stun_server = "stun.l.google.com:19302";
conf.on_state_change = on_state;
conf.on_dc_open = on_dc_open;
conf.on_dc_message = on_dc_message;
xPeerConnection pc = xPeerConnectionCreate(&conf);
/* Create a DataChannel */
xDataChannelConf dc_conf = {0};
strncpy(dc_conf.label, "chat", sizeof(dc_conf.label) - 1);
dc_conf.ordered = true;
xPeerConnectionCreateDataChannel(pc, &dc_conf);
/* Generate offer, exchange via signaling, then: */
// char *offer = xPeerConnectionCreateOffer(pc);
// xPeerConnectionSetLocalDescription(pc, offer);
// ... send offer to remote, receive answer ...
// xPeerConnectionSetRemoteDescription(pc, remote_answer);
xEventLoopRun(loop);
xPeerConnectionDestroy(pc);
xEventLoopDestroy(loop);
return 0;
}
See pc.md for the full PeerConnection API reference, DataChannel API, connection lifecycle, and examples.
ICE Agent (Low-Level)
For raw ICE connectivity without DTLS/SCTP/DataChannel, use the ICE agent directly:
#include <x/base/event.h>
#include <x/p2p/ice_agent.h>
#include <stdio.h>
#include <string.h>
static void on_state(xIceAgent agent, xIceState state, void *arg) {
printf("ICE state: %d\n", state);
if (state == xIceState_Connected) {
const char *msg = "Hello P2P!";
xIceAgentSend(agent, (const uint8_t *)msg, strlen(msg));
}
}
static void on_candidate(xIceAgent agent, const char *sdp, void *arg) {
if (sdp) {
printf("candidate: %s\n", sdp);
} else {
printf("gathering complete\n");
// Exchange SDP with remote peer here
}
}
static void on_data(xIceAgent agent, const uint8_t *data,
size_t len, void *arg) {
printf("received: %.*s\n", (int)len, (const char *)data);
}
int main(void) {
xEventLoop loop = xEventLoopCreate();
xEventLoopEnter(loop);
xIceConf conf = {0};
conf.role = xIceRole_Controlling;
conf.stun_server = "stun.l.google.com:19302";
conf.enable_ipv6 = false;
conf.on_state_change = on_state;
conf.on_candidate = on_candidate;
conf.on_data = on_data;
xIceAgent agent = xIceAgentCreate(&conf);
xIceAgentGather(agent);
// After gathering, exchange SDP with remote peer:
// char *offer = xIceAgentCreateOffer(agent);
// // send offer to remote, receive answer
// xIceAgentSetRemoteDescription(agent, remote_answer);
xEventLoopRun(loop);
xIceAgentDestroy(agent);
xEventLoopDestroy(loop);
return 0;
}
See ice.md for the full ICE agent API reference.
Relationship with Other Modules
- xbase — Uses
xEventLoopfor I/O multiplexing,xSocketfor non-blocking UDP socket management, and timers for ICE connectivity checks and DTLS retransmission. - xbuf — Uses
xBufferfor SDP string assembly andxIOBufferfor DTLS read/write buffering between the ICE and SCTP layers. - xnet — Links against xnet for shared networking types.
- usrsctp — External dependency. Provides user-space SCTP (RFC 4960) for reliable/unreliable message delivery over the DTLS tunnel.
- OpenSSL / mbedTLS — External dependency (DTLS backend, compile-time selection). Provides DTLS 1.2 handshake, encryption, self-signed certificate generation, and SHA-256 fingerprint computation for SDP.
- Application — The
xPeerConnectionAPI exposes a callback-driven interface. Applications create a PeerConnection, exchange SDP offer/answer via a signaling channel, and send/receive messages over DataChannels once connected. For lower-level use, the ICE agent can be used directly.
ICE Agent — ice_agent.h
Overview
xIceAgent is the central component of the xp2p module. It implements the full ICE (Interactive Connectivity Establishment) protocol as defined in RFC 8445, providing NAT traversal and peer-to-peer UDP connectivity.
The agent handles:
- Candidate gathering — Enumerates local network interfaces (host candidates), queries STUN servers (server-reflexive candidates), and optionally allocates TURN relays (relay candidates).
- Connectivity checks — Performs STUN Binding request/response exchanges on all candidate pairs to find working paths.
- Nomination — Selects the best candidate pair for data transport (aggressive nomination in controlling mode).
- Data transport — Sends and receives application data over the nominated pair, with TURN relay fallback via ChannelData framing.
- Consent freshness — Periodically verifies the peer is still reachable (RFC 7675).
Header
#include <x/p2p/ice_agent.h>
States
The ICE agent progresses through the following states:
New → Gathering → Checking → Connected → Completed
↘ ↗
Failed
↓
Closed
| State | Value | Description |
|---|---|---|
xIceState_New | 0 | Initial state, no activity yet |
xIceState_Gathering | 1 | Gathering local candidates (host / srflx / relay) |
xIceState_Checking | 2 | Performing connectivity checks on candidate pairs |
xIceState_Connected | 3 | At least one valid pair found |
xIceState_Completed | 4 | All checks done, nominated pair selected |
xIceState_Failed | 5 | All checks failed, no valid pair |
xIceState_Closed | 6 | Agent has been shut down |
Roles
| Role | Value | Description |
|---|---|---|
xIceRole_Controlling | 0 | Initiates nomination (sends USE-CANDIDATE) |
xIceRole_Controlled | 1 | Accepts nomination from the controlling agent |
Configuration
struct xIceConf {
xIceRole role; // Controlling or Controlled
bool enable_ipv6; // Enable IPv6 candidates (default: false)
const char *stun_server; // STUN server "host:port" (or NULL)
const char *turn_server; // TURN server "host:port" (or NULL)
const char *turn_username; // TURN long-term credential username
const char *turn_password; // TURN long-term credential password
xIceOnStateChange on_state_change; // State change callback
xIceOnCandidate on_candidate; // New candidate callback
xIceOnData on_data; // Data received callback
void *ctx; // Forwarded to all callbacks
};
Callbacks
xIceOnStateChange
typedef void (*xIceOnStateChange)(xIceAgent agent, xIceState state, void *arg);
Called when the agent transitions to a new state. Use this to detect when the connection is established (Connected / Completed) or has failed.
xIceOnCandidate
typedef void (*xIceOnCandidate)(xIceAgent agent, const char *candidate_sdp, void *arg);
Called when a new local candidate is gathered. The candidate_sdp is an SDP candidate line (e.g. "candidate:...") suitable for Trickle ICE. When candidate_sdp is NULL, gathering is complete (end-of-candidates signal).
xIceOnData
typedef void (*xIceOnData)(xIceAgent agent, const uint8_t *data, size_t len, void *arg);
Called when application data is received on the nominated pair. The data buffer is valid only for the duration of the callback.
API Reference
Lifecycle
| Function | Description |
|---|---|
xIceAgentCreate(conf) | Create a new ICE agent. Generates random ice-ufrag/ice-pwd. Returns NULL on failure. |
xIceAgentDestroy(agent) | Destroy the agent, close sockets, cancel timers. Safe to call with NULL. |
Gathering
| Function | Description |
|---|---|
xIceAgentGather(agent) | Start candidate gathering. Enumerates interfaces, sends STUN/TURN requests. Candidates reported via on_candidate. |
SDP Exchange
| Function | Description |
|---|---|
xIceAgentCreateOffer(agent) | Generate an SDP offer string. Caller must free() the result. |
xIceAgentCreateAnswer(agent) | Generate an SDP answer string. Caller must free() the result. |
xIceAgentSetRemoteDescription(agent, sdp) | Parse remote SDP (ice-ufrag, ice-pwd, candidates) and start connectivity checks. |
xIceAgentAddRemoteCandidate(agent, sdp) | Add a single remote candidate (Trickle ICE). |
Data Transport
| Function | Description |
|---|---|
xIceAgentSend(agent, data, len) | Send data through the nominated pair. Only valid in Connected or Completed state. |
Candidate Types
| Type | Priority Pref | Description |
|---|---|---|
host | 126 | Direct local interface address |
srflx | 100 | Server-reflexive (public address from STUN) |
prflx | 110 | Peer-reflexive (discovered during checks) |
relay | 0 | TURN relay address |
Priority is computed per RFC 8445 §5.1.2.1:
priority = (2^24) × type_pref + (2^8) × local_pref + (256 - component_id)
ICE Lifecycle Flow
sequenceDiagram
participant App as Application
participant A as Agent A (Controlling)
participant B as Agent B (Controlled)
participant STUN as STUN Server
App->>A: xIceAgentCreate(conf)
App->>B: xIceAgentCreate(conf)
App->>A: xIceAgentGather()
App->>B: xIceAgentGather()
A->>STUN: STUN Binding Request
B->>STUN: STUN Binding Request
STUN-->>A: Binding Response (srflx addr)
STUN-->>B: Binding Response (srflx addr)
A-->>App: on_candidate(host), on_candidate(srflx), on_candidate(NULL)
B-->>App: on_candidate(host), on_candidate(srflx), on_candidate(NULL)
App->>A: offer = xIceAgentCreateOffer()
App->>B: xIceAgentSetRemoteDescription(offer)
App->>B: answer = xIceAgentCreateAnswer()
App->>A: xIceAgentSetRemoteDescription(answer)
A->>B: STUN Binding Request (connectivity check)
B-->>A: Binding Response
A->>B: STUN Binding Request + USE-CANDIDATE
A-->>App: on_state_change(Connected)
B-->>App: on_state_change(Connected)
App->>A: xIceAgentSend("Hello!")
A->>B: UDP data
B-->>App: on_data("Hello!")
Example — Loopback Echo
The examples/ice_echo.c demo creates two agents in the same process, exchanges SDP, and echoes data:
# Default (host candidates only, no STUN)
./build/ice_echo
# With STUN server
./build/ice_echo -s stun.l.google.com:19302
# Filter to only use server-reflexive candidates
./build/ice_echo -s stun.l.google.com:19302 -f srflx
# Enable IPv6 candidate gathering
./build/ice_echo -6
Command-Line Options
| Flag | Description |
|---|---|
-s host:port | STUN server address (default: stun.l.google.com:19302). Pass -s "" to disable. |
-f type | Filter candidates by type (host, srflx, relay). Default: keep all. |
-6 | Enable IPv6 candidate gathering (disabled by default). |
Protocol Constants
| Constant | Value | Description |
|---|---|---|
XICE_GATHER_TIMEOUT_MS | 5000 | Candidate gathering timeout |
XICE_CHECK_TIMEOUT_MS | 10000 | Connectivity check timeout |
XICE_CHECK_PACING_MS | 50 | Check pacing interval |
XICE_CONSENT_INTERVAL_MS | 15000 | Consent freshness interval (RFC 7675) |
XICE_MAX_CANDIDATES | 32 | Max candidates per agent |
XICE_MAX_PAIRS | 128 | Max candidate pairs |
XSTUN_INITIAL_RTO_MS | 500 | Initial STUN retransmission timeout |
XSTUN_MAX_RETRANSMITS | 7 | Max STUN retransmissions |
PeerConnection — peer_connection.h
Overview
xPeerConnection is the top-level WebRTC API in the xp2p module. It orchestrates the full protocol stack — ICE (connectivity) → DTLS (encryption) → SCTP (transport) → DataChannel (messaging) — into a single, easy-to-use handle that mirrors the browser RTCPeerConnection API.
The PeerConnection manages:
- SDP Negotiation — Create offer/answer, set local/remote descriptions, and add trickle ICE candidates.
- ICE Connectivity — Gathers candidates, performs connectivity checks, and selects the best path.
- DTLS Encryption — Performs a DTLS 1.2 handshake over the ICE transport with self-signed ECDSA P-256 certificates.
- SCTP Association — Establishes a user-space SCTP association (via usrsctp) over the encrypted DTLS channel.
- DataChannel — Implements the DataChannel Establishment Protocol (DCEP, RFC 8832) for creating reliable/unreliable message channels.
Header
#include <x/p2p/peer_connection.h>
Architecture
graph TD
subgraph "Application"
APP["User Application"]
end
subgraph "xPeerConnection"
PC["xPeerConnection<br/>peer_connection.h"]
DC["xDataChannelMgr / xDataChannel<br/>datachannel.h"]
SCTP["xSctpTransport<br/>sctp_transport.h"]
DTLS["xDtlsTransport<br/>dtls_transport.h"]
ICE["xIceAgent<br/>ice_agent.h"]
end
subgraph "xbase"
EV["xEventLoop<br/>event.h"]
end
APP --> PC
PC --> DC
DC --> SCTP
SCTP --> DTLS
DTLS --> ICE
ICE --> EV
style PC fill:#4a90d9,color:#fff
style DC fill:#50b86c,color:#fff
style SCTP fill:#f5a623,color:#fff
style DTLS fill:#e74c3c,color:#fff
style ICE fill:#9b59b6,color:#fff
Protocol Stack
┌─────────────────────────────┐
│ DataChannel (DCEP) │ RFC 8832 — message framing
├─────────────────────────────┤
│ SCTP (usrsctp) │ RFC 4960 — reliable/unreliable streams
├─────────────────────────────┤
│ DTLS 1.2 │ RFC 6347 — encryption
├─────────────────────────────┤
│ ICE (STUN/TURN) │ RFC 8445 — NAT traversal
├─────────────────────────────┤
│ UDP │
└─────────────────────────────┘
Connection States
New → Connecting → Connected → Closed
↘ ↗
Failed / Disconnected
| State | Value | Description |
|---|---|---|
xPeerConnectionState_New | 0 | Initial state, no activity yet. |
xPeerConnectionState_Connecting | 1 | ICE/DTLS/SCTP handshake in progress. |
xPeerConnectionState_Connected | 2 | DataChannel ready for use. |
xPeerConnectionState_Disconnected | 3 | Connectivity lost (may recover). |
xPeerConnectionState_Failed | 4 | Unrecoverable failure. |
xPeerConnectionState_Closed | 5 | Explicitly closed by the application. |
Configuration
struct xPeerConnectionConf {
/* ICE configuration */
const char *stun_server; /* STUN server "host:port" or NULL. */
const char *turn_server; /* TURN server "host:port" or NULL. */
const char *turn_username; /* TURN credential username. */
const char *turn_password; /* TURN credential password. */
bool enable_ipv6; /* Enable IPv6 candidates (default: false). */
/* SCTP port (0 = default 5000). */
uint16_t sctp_port;
/* Callbacks */
xPeerConnectionOnStateChange on_state_change;
xPeerConnectionOnIceCandidate on_ice_candidate;
xPeerConnectionOnDataChannel on_datachannel;
/* Default callbacks for remotely-opened DataChannels. */
xDataChannelOnOpen on_dc_open;
xDataChannelOnMessage on_dc_message;
xDataChannelOnClose on_dc_close;
void *ctx; /* Forwarded to all callbacks. */
};
Callbacks
xPeerConnectionOnStateChange
typedef void (*xPeerConnectionOnStateChange)(xPeerConnection pc,
xPeerConnectionState state,
void *arg);
Called when the overall connection state changes. Use this to detect when the full stack (ICE + DTLS + SCTP) is ready or has failed.
xPeerConnectionOnIceCandidate
typedef void (*xPeerConnectionOnIceCandidate)(xPeerConnection pc,
const char *candidate_sdp,
void *arg);
Called when a new local ICE candidate is gathered. When candidate_sdp is NULL, gathering is complete (end-of-candidates signal). Send each candidate to the remote peer via your signaling channel for Trickle ICE.
xPeerConnectionOnDataChannel
typedef void (*xPeerConnectionOnDataChannel)(xPeerConnection pc,
xDataChannel channel,
void *arg);
Called when the remote peer opens a DataChannel. The channel handle is ready for sending/receiving messages.
API Reference
Lifecycle
| Function | Description |
|---|---|
xPeerConnectionCreate(conf) | Create a new PeerConnection. Internally creates an ICE agent and DTLS transport with a self-signed certificate. Returns NULL on failure. |
xPeerConnectionDestroy(pc) | Destroy the PeerConnection and all owned resources (DataChannel, SCTP, DTLS, ICE). Safe to call with NULL. |
SDP Negotiation
| Function | Description |
|---|---|
xPeerConnectionCreateOffer(pc) | Generate a WebRTC SDP offer. Starts ICE gathering if not already started. Caller must free() the result. |
xPeerConnectionCreateAnswer(pc) | Generate a WebRTC SDP answer. Should be called after SetRemoteDescription with the offer. Caller must free() the result. |
xPeerConnectionSetLocalDescription(pc, sdp) | Set the local SDP description. Starts ICE gathering if not already started. |
xPeerConnectionSetRemoteDescription(pc, sdp) | Parse remote SDP (ICE credentials, DTLS fingerprint, SCTP port) and add remote ICE candidates. |
xPeerConnectionAddIceCandidate(pc, sdp) | Add a single remote ICE candidate (Trickle ICE). |
DataChannel
| Function | Description |
|---|---|
xPeerConnectionCreateDataChannel(pc, conf) | Create a new DataChannel. The channel opens once the SCTP association is established. Returns NULL on failure. |
Accessors
| Function | Description |
|---|---|
xPeerConnectionGetState(pc) | Get the current connection state. |
xPeerConnectionGetIceAgent(pc) | Get the underlying ICE agent handle. |
xPeerConnectionGetDtlsTransport(pc) | Get the DTLS transport handle. |
xPeerConnectionGetSctpTransport(pc) | Get the SCTP transport handle. |
xPeerConnectionGetDataChannelMgr(pc) | Get the DataChannel manager handle. |
DataChannel API
Once a DataChannel is obtained (via xPeerConnectionCreateDataChannel or the on_datachannel callback), use the following APIs:
DataChannel Configuration
struct xDataChannelConf {
char label[256]; /* Channel label. */
char protocol[256]; /* Sub-protocol (optional). */
bool ordered; /* Ordered delivery (default: true). */
uint16_t max_retransmits; /* Max retransmits (0 = reliable). */
uint16_t max_packet_life_time; /* Max lifetime ms (0 = reliable). */
/* Per-channel callbacks (override PeerConnection defaults). */
xDataChannelOnOpen on_open;
xDataChannelOnMessage on_message;
xDataChannelOnClose on_close;
xDataChannelOnError on_error;
void *ctx;
};
DataChannel Functions
| Function | Description |
|---|---|
xDataChannelSendString(channel, str, len) | Send a UTF-8 string message. |
xDataChannelSendBinary(channel, data, len) | Send a binary message. |
xDataChannelClose(channel) | Close the DataChannel. |
xDataChannelGetLabel(channel) | Get the channel label. |
xDataChannelGetState(channel) | Get the current channel state (Connecting, Open, Closing, Closed). |
xDataChannelGetStreamId(channel) | Get the underlying SCTP stream ID. |
DataChannel States
| State | Value | Description |
|---|---|---|
xDataChannelState_Connecting | 0 | OPEN sent, waiting for ACK. |
xDataChannelState_Open | 1 | Channel is open for data. |
xDataChannelState_Closing | 2 | Close initiated. |
xDataChannelState_Closed | 3 | Channel is closed. |
Connection Lifecycle Flow
sequenceDiagram
participant App as Application
participant PC_A as PeerConnection A<br/>(Offerer)
participant PC_B as PeerConnection B<br/>(Answerer)
participant STUN as STUN Server
Note over App,PC_B: 1. Create PeerConnections
App->>PC_A: xPeerConnectionCreate(conf)
App->>PC_B: xPeerConnectionCreate(conf)
Note over App,PC_B: 2. Create DataChannel (offerer side)
App->>PC_A: xPeerConnectionCreateDataChannel(pc, &dc_conf)
Note over App,STUN: 3. Gather ICE candidates
App->>PC_A: xIceAgentGather(xPeerConnectionGetIceAgent(pc))
App->>PC_B: xIceAgentGather(xPeerConnectionGetIceAgent(pc))
PC_A->>STUN: STUN Binding Request
PC_B->>STUN: STUN Binding Request
STUN-->>PC_A: Binding Response
STUN-->>PC_B: Binding Response
PC_A-->>App: on_ice_candidate(candidate)
PC_A-->>App: on_ice_candidate(NULL) — gathering done
PC_B-->>App: on_ice_candidate(NULL) — gathering done
Note over App,PC_B: 4. Exchange SDP
App->>PC_A: offer = xPeerConnectionCreateOffer()
App->>PC_A: xPeerConnectionSetLocalDescription(offer)
App->>PC_B: xPeerConnectionSetRemoteDescription(offer)
App->>PC_B: answer = xPeerConnectionCreateAnswer()
App->>PC_B: xPeerConnectionSetLocalDescription(answer)
App->>PC_A: xPeerConnectionSetRemoteDescription(answer)
Note over PC_A,PC_B: 5. ICE → DTLS → SCTP handshake
PC_A->>PC_B: ICE connectivity checks
PC_A-->>App: on_state_change(Connecting)
PC_A->>PC_B: DTLS handshake (ClientHello / ServerHello)
PC_A->>PC_B: SCTP INIT / INIT-ACK / COOKIE
PC_A-->>App: on_state_change(Connected)
PC_B-->>App: on_state_change(Connected)
Note over PC_A,PC_B: 6. DataChannel open
PC_A->>PC_B: DCEP DATA_CHANNEL_OPEN
PC_B-->>PC_A: DCEP DATA_CHANNEL_ACK
PC_A-->>App: on_dc_open(channel)
PC_B-->>App: on_datachannel(channel)
Note over PC_A,PC_B: 7. Exchange messages
App->>PC_A: xDataChannelSendString(channel, "Hello!")
PC_A->>PC_B: SCTP data
PC_B-->>App: on_dc_message("Hello!")
Example — Loopback Echo
The examples/pc_echo.c demo creates two PeerConnections in the same process, exchanges SDP between them, and echoes a DataChannel message:
#include <x/base/event.h>
#include <x/p2p/peer_connection.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static xEventLoop g_loop;
static xPeerConnection g_pc_a; /* Offerer */
static xPeerConnection g_pc_b; /* Answerer */
static void on_state_change(xPeerConnection pc, xPeerConnectionState state,
void *ctx) {
const char *name = (const char *)ctx;
printf("[%s] State: %d\n", name, state);
}
static void on_dc_open(xDataChannel channel, void *ctx) {
const char *name = (const char *)ctx;
printf("[%s] DataChannel open: %s\n", name, xDataChannelGetLabel(channel));
if (strcmp(name, "PC-A") == 0) {
const char *msg = "Hello DataChannel!";
xDataChannelSendString(channel, msg, strlen(msg));
}
}
static void on_dc_message(xDataChannel channel, xDataChannelMsgType type,
const uint8_t *data, size_t len, void *ctx) {
const char *name = (const char *)ctx;
printf("[%s] Received: %.*s\n", name, (int)len, (const char *)data);
if (strcmp(name, "PC-B") == 0) {
/* Echo back */
xDataChannelSendString(channel, (const char *)data, len);
} else {
printf("Echo successful!\n");
xEventLoopStop(g_loop);
}
}
int main(void) {
g_loop = xEventLoopCreate();
xEventLoopEnter(g_loop);
/* Create offerer */
xPeerConnectionConf conf_a = {0};
conf_a.stun_server = "stun.l.google.com:19302";
conf_a.on_state_change = on_state_change;
conf_a.on_dc_open = on_dc_open;
conf_a.on_dc_message = on_dc_message;
conf_a.ctx = (void *)"PC-A";
g_pc_a = xPeerConnectionCreate(&conf_a);
/* Create answerer */
xPeerConnectionConf conf_b = {0};
conf_b.stun_server = "stun.l.google.com:19302";
conf_b.on_state_change = on_state_change;
conf_b.on_dc_open = on_dc_open;
conf_b.on_dc_message = on_dc_message;
conf_b.ctx = (void *)"PC-B";
g_pc_b = xPeerConnectionCreate(&conf_b);
/* Create DataChannel on offerer */
xDataChannelConf dc_conf = {0};
strncpy(dc_conf.label, "echo", XDC_MAX_LABEL_LEN - 1);
dc_conf.ordered = true;
xPeerConnectionCreateDataChannel(g_pc_a, &dc_conf);
/* Start gathering */
xIceAgentGather(xPeerConnectionGetIceAgent(g_pc_a));
xIceAgentGather(xPeerConnectionGetIceAgent(g_pc_b));
/* After both sides finish gathering, exchange SDP:
* offer = xPeerConnectionCreateOffer(g_pc_a);
* xPeerConnectionSetLocalDescription(g_pc_a, offer);
* xPeerConnectionSetRemoteDescription(g_pc_b, offer);
* answer = xPeerConnectionCreateAnswer(g_pc_b);
* xPeerConnectionSetLocalDescription(g_pc_b, answer);
* xPeerConnectionSetRemoteDescription(g_pc_a, answer);
*/
xEventLoopRun(g_loop);
xPeerConnectionDestroy(g_pc_a);
xPeerConnectionDestroy(g_pc_b);
xEventLoopDestroy(g_loop);
return 0;
}
# Build and run
./build/pc_echo
# With custom STUN server
./build/pc_echo -s stun.l.google.com:19302
# Enable IPv6
./build/pc_echo -6
DTLS Backend
The DTLS layer supports two TLS backends, selected at compile time:
| Backend | CMake Option | Description |
|---|---|---|
| OpenSSL | -DX_TLS_BACKEND=openssl (default) | Uses OpenSSL for DTLS 1.2 handshake and encryption. |
| mbedTLS | -DX_TLS_BACKEND=mbedtls | Uses mbedTLS for DTLS 1.2 handshake and encryption. |
Both backends generate a self-signed ECDSA P-256 certificate at xPeerConnectionCreate time and compute a SHA-256 fingerprint for SDP a=fingerprint.
Thread Safety
| Operation | Thread Safety |
|---|---|
xPeerConnectionCreate() | Call from event loop thread only |
xPeerConnectionDestroy() | Call from event loop thread only |
xPeerConnectionCreateOffer/Answer() | Call from event loop thread only |
xPeerConnectionSetLocal/RemoteDescription() | Call from event loop thread only |
xDataChannelSendString/Binary() | Call from event loop thread only |
| All callbacks | Always invoked on event loop thread |
Error Handling
| Scenario | Behavior |
|---|---|
NULL loop or conf in Create | Returns NULL |
| ICE gathering failure | on_state_change reports Failed |
| DTLS handshake failure | on_state_change reports Failed |
| SCTP association failure | on_state_change reports Failed |
| Invalid remote SDP | SetRemoteDescription returns error xErrno |
| Send on closed DataChannel | Returns xErrno error |
xPeerConnectionDestroy(NULL) | No-op (safe) |
Best Practices
- Exchange SDP after gathering completes — Wait for the
on_ice_candidate(NULL)signal before callingCreateOffer/CreateAnswerto include all candidates in the SDP. Alternatively, use Trickle ICE withAddIceCandidatefor faster setup. - Set callbacks in conf before Create — All callbacks must be configured in
xPeerConnectionConfbefore callingxPeerConnectionCreate. They cannot be changed after creation. - Use per-channel callbacks for complex apps — Set
on_open/on_message/on_closeinxDataChannelConfto override the PeerConnection-level defaults for individual channels. - Destroy in order — Call
xPeerConnectionDestroywhich tears down DataChannel → SCTP → DTLS → ICE in the correct order. Do not destroy sub-components individually. - One event loop thread — All PeerConnection operations and callbacks run on the event loop thread. Do not call PeerConnection APIs from other threads.
Comparison with Other Libraries
| Feature | xp2p PeerConnection | libdatachannel | Pion (Go) | libwebrtc (Google) | webtransport-go |
|---|---|---|---|---|---|
| Language | C99 | C++ | Go | C++ | Go |
| I/O Model | Async (xEventLoop, single-threaded) | Async (internal thread pool) | Goroutines | Multi-threaded | Goroutines |
| ICE | Built-in (RFC 8445, full agent) | Built-in (libnice / libjuice) | Built-in | Built-in | N/A (QUIC) |
| DTLS Backend | Pluggable (OpenSSL / mbedTLS) | GnuTLS / OpenSSL | pion/dtls (pure Go) | BoringSSL | N/A (QUIC TLS) |
| SCTP | usrsctp (user-space) | usrsctp | pion/sctp (pure Go) | usrsctp | N/A |
| DataChannel | DCEP (RFC 8832) | DCEP (RFC 8832) | DCEP (RFC 8832) | DCEP (RFC 8832) | Datagrams / Streams |
| Audio/Video | Not supported (data-only) | Optional (via libSRTP) | Full media stack | Full media stack | Not applicable |
| Binary Size | ~200 KiB (shared lib) | ~1 MiB | ~10 MiB (static) | ~50 MiB | ~5 MiB |
| Dependencies | xbase, usrsctp, OpenSSL or mbedTLS | usrsctp, GnuTLS/OpenSSL | Pure Go (zero CGo) | Many (build system) | Pure Go |
| Thread Model | Single event loop thread | Internal thread pool | Per-connection goroutines | Complex multi-threaded | Per-connection goroutines |
| API Style | C function pointers (callbacks) | C++ lambdas / callbacks | Go interfaces / channels | C++ observers | Go interfaces |
Key Differentiator: xp2p provides a lightweight, data-only WebRTC stack in pure C99 with a single-threaded event-driven architecture. Unlike libwebrtc (which bundles a full media engine at ~50 MiB), xp2p focuses exclusively on DataChannel connectivity with minimal footprint (~200 KiB). The pluggable DTLS backend (OpenSSL or mbedTLS) makes it suitable for both server and embedded environments. Compared to libdatachannel (the closest C/C++ alternative), xp2p integrates directly with xbase's event loop — no internal thread pool — giving the application full control over scheduling and avoiding synchronization overhead.
Relationship with Other Modules
- xbase — Uses
xEventLoopfor I/O multiplexing,xSocketfor non-blocking UDP socket management, and timers for ICE connectivity checks and DTLS retransmission. - xbuf — Uses
xBufferfor SDP string assembly andxIOBufferfor DTLS read/write buffering between the ICE and SCTP layers. - usrsctp — External dependency. Provides user-space SCTP (RFC 4960) for reliable/unreliable message delivery over the DTLS tunnel. Runs its own timer thread for retransmission.
- OpenSSL / mbedTLS — External dependency (DTLS backend, compile-time selection via
X_TLS_BACKEND). Provides DTLS 1.2 handshake, encryption, self-signed certificate generation, and SHA-256 fingerprint computation for SDP.
fs.h — Async Filesystem I/O
Introduction
fs.h provides a minimal async filesystem API built on top of task.h's thread pool. All I/O operations (open, close, read, write, stat, directory management, rename, unlink) are offloaded to worker threads, with completion callbacks delivered on the event loop thread. A synchronous mode is available by passing cb = NULL — the call blocks until the operation completes.
Design Philosophy
-
Single Request Struct — All operations share one
xFsReqstruct. Theopfield selects the operation; required fields depend on the op. This avoids function explosion (xFsOpen,xFsRead, …) and makes batch submission trivial. -
Thread Pool Offload — Filesystem operations are blocking by nature (
pread,pwrite,stat,rename). Rather than invent async filesystem syscalls,fs.hdelegates totask.h's N:M thread pool. Worker threads perform the actual syscall; the done callback fires on the event loop thread viaxEventLoopPost. -
Dual Mode (Async / Sync) — When
req->cbis non-NULL,xFsReqSubmitreturnsxErrno_Pendingand the callback is invoked on the event loop thread when the operation completes. Whencbis NULL, the call blocks the current thread and returns the result directly — useful for scripts, tests, or startup sequences where event loop orchestration is unnecessary. -
Zero-Copy Buffer Model — The caller owns
req->bufandreq->path. They must remain valid until the callback fires (async mode) or the call returns (sync mode). No internal copies are made. -
Cancellation-Aware —
xFsReqCanceldelegates toxWorkCancel, which uses CAS to atomically cancel a queued-but-not-yet-running task. After cancel, the callback is NOT invoked and bothreqand its buffers can be safely released.
Architecture
Application
│
├── xFsReqSubmit(req) [async, cb != NULL]
│ │
│ └── xWorkSubmit(worker_pool, fs_worker, fs_done, req)
│ │
│ ├── Enqueued to thread pool
│ ├── Worker thread: open/read/write/stat/...
│ └── Done: xEventLoopPost → fs_done → req->cb(req)
│
└── xFsReqSubmit(req) [sync, cb == NULL]
└── fs_worker(req) on calling thread
→ blocks until complete
API Reference
Types
| Type | Description |
|---|---|
xFile | Opaque file handle. On Unix: int cast to xFile. On Windows: HANDLE. |
xFsOp | Operation enum: xFsOpOpen, xFsOpClose, xFsOpRead, xFsOpWrite, xFsOpStat, xFsOpMkdir, xFsOpRmdir, xFsOpUnlink, xFsOpRename |
xFsStat | Stat result: size (off_t), mode (int), mtime (uint64_t ms), ctime (uint64_t ms) |
xFsReq | Per-operation request struct (see below) |
xFsFunc | typedef void (*xFsFunc)(xFsReq *req) — completion callback |
xFsReq
| Field | Type | Description |
|---|---|---|
op | xFsOp | Operation to perform |
path | const char * | File/directory path (Open, Stat, Mkdir, Unlink, Rename, Rmdir) |
buf | void * | Data buffer (Read, Write). For Rename: holds the new path string |
len | size_t | Buffer length (Read, Write) |
offset | off_t | File offset (Read, Write) |
flags | int | Open flags: O_RDONLY, `O_CREAT |
mode | int | File/directory mode: 0644, 0755, etc. (Open, Mkdir) |
file | xFile | File handle (Close, Read, Write) |
cb | xFsFunc | Completion callback. NULL = synchronous blocking call |
arg | void * | User data passed through to callback |
result | xErrno | Output. Operation result: xErrno_Ok on success |
retval | ssize_t | Output. Bytes read/written, or -1 on error |
done | bool | Output. True on the last callback invocation (streaming reads) |
stat | xFsStat | Output. Stat result (xFsOpStat) |
out_file | xFile | Output. Opened file handle (xFsOpOpen) |
Required Fields Per Operation
| Operation | Required Input Fields | Output Fields |
|---|---|---|
xFsOpOpen | path, flags, mode, cb | result, retval, out_file |
xFsOpClose | file, cb | result, retval |
xFsOpRead | file, buf, len, offset, cb | result, retval, done |
xFsOpWrite | file, buf, len, offset, cb | result, retval, done |
xFsOpStat | path, cb | result, stat |
xFsOpMkdir | path, mode, cb | result, retval |
xFsOpUnlink | path, cb | result, retval |
xFsOpRmdir | path, cb | result, retval |
xFsOpRename | path (old), buf (new name), cb | result, retval |
Functions
| Function | Signature | Description |
|---|---|---|
xFsReqSubmit | xErrno xFsReqSubmit(xFsReq *req) | Submit an async or sync filesystem operation. Returns xErrno_Pending for async (callback will fire), or the result directly for sync. |
xFsReqCancel | xErrno xFsReqCancel(xFsReq *req) | Cancel a pending request. Callback will NOT be invoked. Safe with NULL. |
Usage Examples
Async Open / Close
#include <x/base/event.h>
#include <x/fs/fs.h>
xFsReq r = {0};
r.op = xFsOpOpen;
r.path = "/tmp/example.txt";
r.flags = O_CREAT | O_RDWR | O_TRUNC;
r.mode = 0644;
r.cb = [](xFsReq *r) {
if (r->result == xErrno_Ok) {
// r->out_file is the open file handle
xFsReq close = {0};
close.op = xFsOpClose;
close.file = r->out_file;
close.cb = [](xFsReq *r) { xEventLoopStop(xEventLoopCurrent()); };
xFsReqSubmit(&close);
}
};
xFsReqSubmit(&r); // returns xErrno_Pending
xEventLoopRun(loop, X_RUN_DEFAULT);
Sync Write / Read
xFsReq r = {0};
// Open
r.op = xFsOpOpen;
r.path = "/tmp/data.bin";
r.flags = O_CREAT | O_RDWR | O_TRUNC;
r.mode = 0644;
assert(xFsReqSubmit(&r) == xErrno_Ok);
xFile f = r.out_file;
// Write
const char *msg = "hello fs!";
r.op = xFsOpWrite;
r.file = f;
r.buf = (void *)msg;
r.len = strlen(msg);
assert(xFsReqSubmit(&r) == xErrno_Ok);
// Read back
char buf[64] = {0};
r.op = xFsOpRead;
r.buf = buf;
r.len = sizeof(buf);
r.offset = 0;
assert(xFsReqSubmit(&r) == xErrno_Ok);
assert(strcmp(buf, "hello fs!") == 0);
// Close
r.op = xFsOpClose;
xFsReqSubmit(&r);
Stat
xFsReq r = {0};
r.op = xFsOpStat;
r.path = "/tmp";
assert(xFsReqSubmit(&r) == xErrno_Ok);
printf("size: %lld, mode: %o, mtime: %llu\n",
r.stat.size, r.stat.mode, r.stat.mtime);
Directory Operations
xFsReq r = {0};
// Create directory
r.op = xFsOpMkdir;
r.path = "/tmp/my_dir";
r.mode = 0755;
assert(xFsReqSubmit(&r) == xErrno_Ok);
// Rename
r.buf = (void *)"/tmp/my_dir_renamed"; // new path in buf
r.op = xFsOpRename;
assert(xFsReqSubmit(&r) == xErrno_Ok);
// Remove directory
r = (xFsReq){0};
r.op = xFsOpRmdir;
r.path = "/tmp/my_dir_renamed";
assert(xFsReqSubmit(&r) == xErrno_Ok);
Platform Notes
| Aspect | Unix | Windows |
|---|---|---|
xFile | int fd cast to xFile | HANDLE from CreateFile |
| Open flags | POSIX `O_CREAT | O_RDWR` etc. |
| Stat | POSIX stat(2) | Windows _stat64 |
| Thread pool | pthread-based task.h worker | Same via task.h abstraction |
Thread Safety
xFsReqSubmit: Thread-safe — can be called from any thread. The worker executes on a pool thread; the callback fires on the event loop thread.xFsReqCancel: Thread-safe — delegates toxWorkCancelwhich uses CAS.- Buffers and paths: The caller is responsible for ensuring
req->path,req->buf, and thexFsReqitself remain valid until the callback fires (async) or the call returns (sync). No internal copies are made.
Error Handling
| Return / Result | Meaning |
|---|---|
xErrno_Pending | Async submission accepted. Callback will fire. |
xErrno_Ok | Operation completed successfully (sync mode) or callback reports success. |
xErrno_InvalidArg | req is NULL, or required fields are missing. |
xErrno_SysError | Underlying syscall failed (open, read, write, stat, etc.). Check errno. |
xErrno_NoMemory | Thread pool queue is full or allocation failed. |
See Also
task.h— Thread pool (xWork,xWorkSubmit,xWorkCancel) that powers the async pathevent.h— Event loop where callbacks are deliverederror.h— Error code definitions (xErrno)