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/BufWriter for efficient buffered I/O, io::copy() and io::read_all() for common patterns, Duplex/Simplex for in-process communication
  • Network: TcpStream for async TCP, TcpListener for accepting connections, UdpSocket, DNS resolution, TLS with OpenSSL or mbedTLS
  • FileSystem: async File with cursor tracking, stat, directory operations
  • Channels: oneshot (single-value), mpsc (bounded and unbounded), broadcast (multi-consumer with lag detection), watch (version-tracked latest value), plus Notify for 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>, and NonNull<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 AsyncFd up through BufReader/BufWriter to type-safe TcpStream and File, plus utilities like io::copy and in-process Duplex/Simplex pipes.
  • Channels — the full Tokio-aligned suite: oneshot, mpsc (bounded via lock-free ring buffer, unbounded via lock-free linked list), broadcast with lag recovery, watch with version-tracked "seen" semantics, and Notify as a reusable wake primitive.
  • Threading Model — Arc<T> (atomic refcount) for all shared library state — the old XPP_MT switch and Shared<T> alias were removed, Rc<T> remains for explicit single-threaded use — the loom module 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 on Promise<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 项目设计哲学

English

为什么要做这个项目

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 drives xEventLoopRun directly. Inside a fiber (via xpp::fiber()) it suspends via xFiberYield — non-blocking, stackful, M:N concurrency without co_await syntax. C++20 coroutines are a first-class option too.
  • Rust-inspired, C++11-compatible — Result/Option/Arc/Box with 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 also sizeof(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's Arc::new.

Modules

  • EventLoop & WaitScope — RAII wrappers for the libx event loop
  • Fiber — Stackful coroutines via xpp::fiber() + .await()
  • Promise — Composable deferred values
  • 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_HANDLE typedefs

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

  1. Rust-style &self. allocate and deallocate are const-qualified. Stateful allocators track state via mutable members or atomic pointers — std::atomic<T>::fetch_add is itself const, so counters held by pointer work without any mutable dance. This lets callers pass const Allocator& if they only have a const reference.

  2. 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.

  3. Fat-pointer return. allocate returns Result<Span<uint8_t>, AllocError> rather than Result<void*, AllocError>. The Span carries 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.

  4. Empty allocator → zero overhead. GlobalAllocator is an empty class. EBO (via inheritance for ArcInner/RcInner, via CompressedPair for Own/Box) collapses it to zero bytes, so sizeof(Arc<T>) == sizeof(T*) with the default allocator.

  5. Allocator lives in the control block, not the handle. For Arc/Rc, the Allocator instance is stored inside ArcInner/RcInner — not inside the Arc/Rc handle. This keeps sizeof(Arc<T, Allocator>) == sizeof(T*) for any A, stateful or not. For Own/Box, the Allocator is stored via CompressedPair<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&lt;uint8_t&gt; { data, size }"]
        E["AllocError (empty)"]
        A["Allocator::allocate(Layout) const → Result&lt;Span, AllocError&gt;"]
        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&lt;T, A&gt; { strong, weak, value, alloc }"]
        OWN["CompressedPair&lt;T*, A&gt; { 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

SignatureDescription
Result<Span<uint8_t>, AllocError> allocate(Layout layout) constAllocate 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 noexceptFree memory previously returned by allocate. layout must match the Layout passed to allocate.

Optional methods

SignatureDescription
Result<Span<uint8_t>, AllocError> grow(void *ptr, Layout old_l, Layout new_l) constGrow 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) constShrink 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

TypeDefault AllocatorStateless customStateful 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 SFINAEExplicitExplicit
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

Featurexpp Allocatorstd::pmr::memory_resourceRust Allocator trait
Protocolallocate/deallocate methodsdo_allocate/do_deallocate virtualsallocate/deallocate methods
Return typeResult<Span<uint8_t>, AllocError>void* (throws on failure)Result<NonNull<[u8]>, AllocError>
LayoutLayout { size, align }size_t + size_t argsLayout struct
grow / shrinkOptional, default impl providedNot in APIOptional, default impl provided
Empty alloc EBOYes (inheritance / CompressedPair)N/A (type-erased)Yes (zero-sized type)
Storage in smart pointerControl block (Arc/Rc) or CompressedPair (Own/Box)N/AControl block (Arc/Rc)
Const-qualifiedYes (allocate/deallocate are const)No (virtual, mutates vtable state)Yes (&self)
Type-erasedNo (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> — portable is_final (C++14 / __is_final intrinsic / fallback to false)
  • FirstIsAlloc<Allocator, Args...> — SFINAE: true iff first arg in Args... is convertible to Allocator
  • destroy_and_dealloc<T, Allocator>(ptr, alloc) — calls ~T() (if not void) then alloc.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

  1. 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 or reset().

  2. Compile-time size, automatic storage. The template parameter N determines the buffer size. ArenaStorage<N> specializes at compile time: N ≤ 256 → inline (the buffer is a member of the Arena object), N > 256 → heap (the buffer is ::operator new'd at construction). The user writes Arena<128> or Arena<4096> and the storage strategy is automatic.

  3. nullptr on overflow, not auto-grow. When the arena is full, allocate() returns nullptr. The caller checks and falls back to heap. This keeps the arena simple (no chunk list, no growth logic) and makes owns() a trivial O(1) pointer range check.

  4. No destructor tracking. The arena does not call ~T() — the caller is responsible for destructing objects before reset() or arena destruction. This keeps the arena zero-overhead. make<T>() constructs in-place, but the caller must call ~T() manually.

  5. C++11, header-only, no dependencies. arena.h includes 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>

MethodReturnsDescription
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)boolTrue if p is within this arena's buffer. O(1).
reset()m_pos = begin. Buffer stays allocated for reuse.
total_capacity()size_tBuffer size N (constexpr).
remaining()size_tBytes left before overflow.
used()size_tBytes 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

Featurexpp::Arena<N>kj::ArenaxSlab
AllocationBump (forward)Bump (forward)Free-list (fixed size)
SizeCompile-time (template param)Runtime (constructor param)Runtime (constructor param)
GrowthFixed — nullptr on overflowChunk list (auto-grow)Fixed — nullptr on overflow
Individual freeNoNoYes (free-list)
Reset/reuseYes (reset())NoYes (xSlabReset)
owns()O(1) pointer range checkO(1)O(1)
Inline storageYes (N ≤ 256, zero malloc)No (always heap)No (always heap)
DestructorsNo trackingTracked (reverse order)No tracking
LanguageC++11C++14C99
Thread safetySingle-threadSingle-threadSingle-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

← libxpp

Rust-inspired smart pointers with sizeof == sizeof(T*) guarantees. All are header-only, C++11-compatible.

Overview

TypeOwnershipThread-safeHeader
Own<T, Allocator>Unique, nullableNoown.h
Box<T, Allocator>Unique, non-nullNobox.h
Rc<T, Allocator>SharedNorc.h
Weak<T, Allocator>Weak observer for RcNoweak.h
Arc<T, Allocator>SharedYes (atomic)arc.h
ArcWeak<T, Allocator>Weak observer for ArcYes (atomic)arc.h
NonNull<T>Non-owning, non-nullNononnull.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-word shared_ptr layout.
  • Niche-optimized Option: Option<Arc<T>> and Option<Rc<T>> are also sizeof(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: Allocator parameter (default GlobalAllocator) controls allocation/deallocation. Stored in control block (Arc/Rc) or via CompressedPair (Own/Box) with EBO. See Allocator.
  • Arc memory orders: relaxed for clone, release for drop, acquire fence 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> — plain int refcount, zero atomic overhead, for event-loop or single-thread code
  • Arc<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

NeedSTL gives youxpp gives you
Maybe-null, unique ownershipunique_ptr<T>Own<T>
Never-null, unique ownership—Box<T>
Maybe-null, shared ownershipshared_ptr<T>Rc<T> or Arc<T>
Maybe-null, non-owning observerweak_ptr<T>Weak<T> or ArcWeak<T>
Never-null, non-owning pointerraw 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

Featurexppstd
sizeof (unique)sizeof(T*)sizeof(T*)
sizeof (shared)sizeof(T*)2 × sizeof(T*)
Non-null defaultBox<T>—
Niche OptionYes (nullptr = None)No
Single-thread sharedRc<T> (no atomics)shared_ptr (always atomic)
Thread-safe sharedArc<T>shared_ptr
Custom allocatorYes (Allocator template param, compile-time)std::pmr (type-erased, runtime)
Allocator storageControl block (Arc/Rc) or CompressedPair (Own/Box), EBO when emptyvtable ptr in control block (always)
Deallocation~T() + alloc.deallocate() (separated)deleter(ptr) (single call)
Covariant upcastImplicit (same Allocator)Implicit
Weak observerWeak<T> / ArcWeak<T>weak_ptr<T>
Promise interopNative (.then(), into_nonnull())N/A
Control blockCo-located (single alloc)Separate or intrusive
Header-onlyYesYes

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 (like Option::take), into_nonnull() converts to Option<Box<T>> for combinator usage.
  • C++ RAII — reset(), release(), operator*, operator->, get(), operator bool all work as expected.

At rest, Own<T> is sizeof(T*) when using the default GlobalAllocator (empty-base optimization eliminates the allocator storage).

Design Philosophy

  1. Nullable by default. Own<T> can be null — default-constructed, moved-from, or assigned nullptr. Use if (own) to check. If you need a type-level guarantee of non-null, use Box<T> directly.

  2. Box<T> as the foundation. Own<T> is implemented as Option<Box<T, Allocator>>. The Box<T> type is non-null by construction; wrapping it in Option adds the null state. This means all Own<T> operations ultimately delegate to Box<T> for resource management.

  3. Allocator via EBO. The default GlobalAllocator is an empty class. C++ empty-base optimization (EBO) collapses it to zero size, so sizeof(Own<T>) == sizeof(T*). Stateful allocators add their own size.

  4. Covariant construction. Own<Derived, Allocator> implicitly converts to Own<Base, Allocator> (if the pointer is convertible). Same Allocator required — different Allocator types would have different CompressedPair layouts.

  5. Void specialization. Own<void> stores a raw pointer without operator* or operator->. Useful for opaque handles where only reset() and get() matter. SFINAE removes the dereference operators when T = void. The destructor calls deallocate(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 {
        &lt;&lt;non-null&gt;&gt;
        +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

ExpressionResult
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

MethodDescription
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

MethodReturnsOn 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

MethodReturnsDescription
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>>
NullableYes (default)Yes (default)Yes (via Option)
Move-onlyYesYesYes
Release/taketake() / release()release()Option::take + Box::into_raw
Custom allocatorAllocator template paramDeleter template paramA: Allocator
Allocator storageCompressedPair (EBO when empty)EBO (empty-base optimization)In Box (ZST = 0 bytes)
Deallocation~T() + alloc.deallocate() (separated)deleter(ptr) (single call)drop + dealloc
CovariantOwn<Derived, A> → Own<Base, A> (same A)unique_ptr<Derived, D> → unique_ptr<Base, D>Via trait objects only
Into Rust pathinto_nonnull() -> Option<Box<T>>N/ABuilt-in
Void supportYes (SFINAE on * / ->)Yes (specialization)Box<dyn Any>
Size (default)sizeof(T*)sizeof(T*)sizeof(T*)
Debug assert on null derefYes (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 operationUnderlying
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 Own → Own). Same Allocator 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

  1. 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.

  2. EBO via CompressedPair. CompressedPair<T, Allocator> uses private inheritance for empty allocators to achieve zero storage overhead — sizeof(Box<T, GlobalAllocator>) == sizeof(T*).

  3. Niche optimization for Option<Box<T>>. The Option<Box<T>> specialization stores a single CompressedPair; nullptr means None. No bool tag, no wasted bytes — matches Rust exactly.

  4. Covariant construction. Box<Derived, A> implicitly moves into Box<Base, A> (and Option<Box<Base, A>>) when the pointer and allocator are convertible — matching std::unique_ptr's behavior.

  5. Move-only with a "husk" state. Post-move, the source holds nullptr internally. This violates the public invariant but is hidden — the only valid operation on a moved-from Box is destruction (which guards on null). This matches std::unique_ptr's post-move contract.

Architecture

graph TD
    subgraph "User API"
        BOX["Box&lt;T, D&gt;"]
        FROM_RAW["from_raw(p, d)"]
        TRY_FROM_RAW["try_from_raw(p, d) → Option"]
        INTO_RAW["into_raw()"]
        OPT_BOX["Option&lt;Box&lt;T, D&gt;&gt;"]
    end

    subgraph "Storage"
        CP["CompressedPair&lt;T*, D&gt;"]
        EBO["Empty allocator → inherit<br/>Stateful → member"]
    end

    subgraph "Related Types"
        NN["NonNull&lt;T&gt;"]
        OWN["Own&lt;T, D&gt;"]
        OPT["Option&lt;T&gt;"]
    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>

MemberDescription
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 &&.

MemberReturns 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

Featurexpp::Box<T>std::unique_ptr<T>Rust Box<T>
sizeofsizeof(T*)sizeof(T*) (default deleter)sizeof(T*)
Non-nullGuaranteed (no default ctor)Nullable (default ctor)Guaranteed
Move-onlyYesYesYes
Custom allocatorAllocator template paramDeleter template paramA: Allocator
Allocator storageCompressedPair (EBO when empty)EBO (empty-base optimization)In Box (ZST = 0 bytes)
Deallocation~T() + alloc.deallocate() (separated)deleter(ptr) (single call)drop + dealloc
CovariantBox<Derived, A> → Box<Base, A> (same A)unique_ptr<Derived, D> → unique_ptr<Base, D>Via DerefMut trait
Niche OptionYes (Option<Box<T>> = ptr)NoOption<Box<T>> = ptr
EBOYes (CompressedPair)Via empty-base optimizationN/A (ZST, no EBO needed)
Post-movenullptr 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:

PropertyValue
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*)
Allocation1× per make() (inner block = counts + T + Allocator)
Thread safetySingle-thread only (use Arc<T, Allocator> for cross-thread)
Default AllocatorGlobalAllocator (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&lt;T&gt;::make(args)"]
        INNER["RcInner&lt;T&gt; — heap (strong=1, weak=1, value)"]
        MAKE --> |"single ::operator new"| INNER
    end

    subgraph "Ownership"
        RC["Rc&lt;T&gt; — sizeof = T*"]
        RC2["Rc&lt;T&gt; (clone) — strong += 1"]
        W["Weak&lt;T&gt; — 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:

  1. strong hits 0 → destroy T in place
  2. Decrement weak (this is the "+1 for all strongs" unwinding)
  3. If weak now 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>

CategorySignatureDescription
ConstructRc<T>::make(args...)Single allocation: inner + T. strong=1, weak=1.
CopyRc(const Rc&)+1 strong (implicit on copy)
MoveRc(Rc&&)Zero count change; source invalidated
Covariant copyRc<Base>(const Rc<Derived>&)Same inner, +1 strong
Covariant moveRc<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
DowngradeRc<T>::downgrade(&r) → Weak<T>Creates observer; weak += 1
Deref*r, r->field, r.get() → T&, T*Direct access through the inner
Countsr.strong_count(), r.weak_count()Debug/instrumentation (do not branch on)
Swapr.swap(other), swap(r1, r2)Exchange inners

Rc<T> has no default constructor — it is always non-null when valid.

Weak<T>

CategorySignatureDescription
DefaultWeak()Null weak (no inner observed)
From RcWeak(const Rc<T>&)Observe; weak += 1
Copy / MovestandardCopy bumps weak; move doesn't
Upgradew.upgrade() → Option<Rc<T>>Some if strong>0, else None
Countsw.strong_count(), w.weak_count()Debug only
Expiredw.is_expired()True if strong==0 or null
Swapw.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):

CategorySignatureDescription
From RcOption(const Rc<T>&), Option(Rc<T>&&)Some, +1 strong or move
Unwrapopt.unwrap() → Rc<T> (rvalue)Takes ownership; panics on None
Takeopt.take() → Rc<T>Moves value out, leaves None
Checkis_some(), is_none(), operator boolStandard 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

Featurexpp::Rc<T>std::shared_ptr<T>Rust Rc<T>
sizeofsizeof(T*)2× ptr (ptr + ctrl)sizeof(T*)
Allocation1× per makeConstructor from ptr: 2×; make_shared: 1×1× per Rc::new
Thread-safeNo (plain size_t)Yes (atomic)No
Non-null by defaultYes (no default ctor)No (default → null)Yes (Rc::new → non-null)
Niche OptionYes (Option<Rc> = ptr)NoYes (Option<Rc> = ptr)
Weak observerWeakweak_ptrWeak
Cycle-breakingExplicit via WeakExplicit via weak_ptrExplicit via Weak
Custom allocatorYes (Allocator template param, in RcInner)Yes (std::pmr, type-erased in ctrl block)Yes (A: Allocator, in RcBox)
Allocator overhead0 bytes when empty (EBO)1 vtable ptr (always)0 bytes when ZST
Deallocation~T() + alloc.deallocate()deleter(ptr) (single call)drop + dealloc
CovariantRc<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:

PropertyValue
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*)
Allocation1× per make()
Thread safetyYes — clone/drop/upgrade safe across threads
Overhead vs Rc~3–5× on contended core; free on uncontended cache
Default AllocatorGlobalAllocator (empty → EBO → zero overhead)

Design Philosophy

  1. Same shape as Rc. Arc<T> mirrors Rc<T> in API and layout. The only difference is std::atomic<size_t> instead of plain size_t, plus the atomic operations with the memory order discipline below.

  2. Proven memory order. The acquire/release pattern is the same one used by Rust's libstd, triomphe, and boost::atomic_shared_ptr — battle-tested across millions of crates and deployments.

  3. Fence-on-drop, not fence-everywhere. Clone uses memory_order_relaxed (no synchronisation needed for mere ownership transfer). Only the thread that actually destroys T or frees the inner pays the acquire fence cost. All other threads pay only fetch_add/fetch_sub — the cheapest atomic RMW the hardware offers.

  4. 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.

  5. 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&lt;T&gt;::make()"]
        A2["Arc&lt;T&gt; clone — fetch_add(1, relaxed)"]
        A3["Arc&lt;T&gt; drop — fetch_sub(1, release)"]
    end

    subgraph "Thread B"
        B1["Arc&lt;T&gt; clone — fetch_add(1, relaxed)"]
        B2["Arc&lt;T&gt; drop — fetch_sub(1, release)"]
        B3["ArcWeak::upgrade() — CAS loop on strong"]
    end

    subgraph "Inner Block (heap)"
        I["ArcInner&lt;T&gt;"]
        S["strong: atomic&lt;size_t&gt;"]
        W["weak: atomic&lt;size_t&gt;"]
        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

OperationOrderingRationale
strong.fetch_add(1) (clone)relaxedNo synchronisation; ownership alone carries no happens-before
strong.fetch_sub(1) (drop)releaseAll previous writes to T must be visible to the destroying thread
When strong hits 0atomic_thread_fence(acquire)Pair with every prior owner's release so ~T() sees all writes
weak.fetch_add(1) (clone)relaxedSame as strong clone
weak.fetch_sub(1) (drop)releasePair with acquire fence in deallocator
When weak hits 0atomic_thread_fence(acquire)Pair with every prior weak drop
ArcWeak::upgrade() CASacquire on success, relaxed on failureSuccess must sync with prior strong drops

API Reference

Arc<T>

Identical API to Rc<T> — all operations are atomic under the hood.

CategorySignatureDescription
ConstructArc<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.
CopyArc(const Arc&)+1 strong (relaxed)
MoveArc(Arc&&)Zero count change; source invalidated
CovariantArc<Base, A>(const Arc<Derived, A>&)Same inner, +1 strong. Same A required.
Clonea.clone(), Arc<T, Allocator>::clone(&a)Explicit +1
DowngradeArc<T, Allocator>::downgrade(&a) → ArcWeak<T, Allocator>+1 weak, strong unchanged
Deref*a, a->field, a.get() → T&, T*Access through inner
Countsa.strong_count(), a.weak_count()Relaxed loads (do not branch on)
Swapa.swap(other), swap(a1, a2)Exchange inners

ArcWeak<T>

CategorySignatureDescription
DefaultArcWeak()Null observer
From ArcArcWeak(const Arc<T>&)+1 weak (relaxed)
Copy / MovestandardCopy bumps weak; move doesn't
Upgradew.upgrade() → Option<Arc<T>>CAS loop. Some if strong>0, else None
Countsw.strong_count(), w.weak_count()Relaxed loads (debug only)
Expiredw.is_expired()True if strong==0 or null
Swapw.swap(other), swap(w1, w2)Exchange inners

Option<Arc<T>> Specialization

Same niche optimization as Option<Rc<T>>: nullptr = None, sizeof == sizeof(T*).

CategorySignatureDescription
From ArcOption(const Arc<T>&), Option(Arc<T>&&)Some, +1 strong or move
Unwrapopt.unwrap() → Arc<T> (rvalue)Takes ownership; panics on None
Takeopt.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

Featurexpp::Arc<T>std::shared_ptr<T>Rust Arc<T>
sizeofsizeof(T*)2× ptrsizeof(T*)
Allocation1× per make()make_shared: 1×; ptr ctor: 2×1× per Arc::new
Thread-safeYes (atomic)Yes (atomic)Yes (atomic)
Memory orderacquire/release (explicit)acquire/release (spec-mandated)acquire/release
Niche OptionYesNoYes
Weak observerArcWeak<T>weak_ptr<T>Weak<T>
Weak upgradeCAS looplock() (atomic)CAS loop
Custom allocatorYes (Allocator template param, in ArcInner)Yes (std::pmr, type-erased in ctrl block)Yes (A: Allocator, in ArcInner)
Allocator overhead0 bytes when empty (EBO)1 vtable ptr (always)0 bytes when ZST
Deallocation~T() + alloc.deallocate() (separated)deleter(ptr) (single call)drop + dealloc
CovariantArc<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:

ApproachCompile-time non-null?Nullable via ?Size
T*NoCheck manually8 bytes
Option<T*>Nois_some()16 bytes
NonNull<T>YesN/A8 bytes
Option<NonNull<T>>Yes (when Some)is_some()8 bytes

API Reference

NonNull<T>

MemberDescription
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>>

MethodReturnsNotes
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)Rfn returns Option<U>
filter(pred)Option<NonNull<T>>Consuming (rvalue)
inspect(fn)ChainableSide 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:

  1. !std::is_void<U> — void& is ill-formed, so the constructor is removed for NonNull<void>.
  2. std::is_same<U, T> — prevents GCC from preferring this template over the implicit copy constructor when a NonNull<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

  1. Always holds something. No default constructor, no null state. A Result must be initialized with Ok or Err.

  2. Three unwrap trust levels. unwrap() always checks (release too), unwrap_unchecked() only in debug, operator*() / operator->() never check — identical to Option's pattern.

  3. 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.

  4. Void success via specialization. Result<void, E> avoids the OkSentinel dance: a zero-size tag type marks the Ok variant, operator* and operator-> are removed, and combinators accept zero-arg functions.

  5. Option <-> Result bridge. Option::ok_or(e) → Result<T, E>, Result::ok() → Option<T>, Result::err() → Option<E>, Result::transpose() → Option<Result<U, E>> when T = Option<U>.

Architecture

graph TD
    subgraph "User API"
        OK["ok(value) → OkResult&lt;T&gt;"]
        ERR["err(e) → ErrResult&lt;E&gt;"]
        RESULT["Result&lt;T, E&gt;"]
        VOID_R["Result&lt;void, E&gt;"]
    end

    subgraph "Type System"
        ENUM["Enum&lt;T, E&gt;"]
        OPTION["Option&lt;T&gt;"]
        IS_OPT["is_option&lt;T&gt; trait"]
    end

    subgraph "Combinators"
        MAP["map(fn) → Result&lt;U, E&gt;"]
        MAP_ERR["map_err(fn) → Result&lt;T, F&gt;"]
        AND_THEN["and_then(fn) → Result&lt;U, E&gt;"]
        OR_ELSE["or_else(fn) → Result&lt;T, F&gt;"]
        TRANSPOSE["transpose() → Option&lt;Result&lt;U, E&gt;&gt;"]
        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

ExpressionResult
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

MethodReturnsPanics if
is_ok()boolNever
is_err()boolNever
operator bool()bool (explicit)Never

Unwrap (checked)

MethodReturnsPanics 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)

MethodReturns
unwrap_unchecked()T&
unwrap_err_unchecked()E&

Convenience

MethodReturnsNotes
unwrap_or(fallback)const T& / TFallback 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

MethodSignatureDescription
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

MethodReturnsNotes
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

MemberNotes
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 stateNoneNoneNone
unwrap() panicsAlways (release too)AlwaysThrows bad_expected_access
ok() / err() factoryok(v) / err(e)Ok(v) / Err(e)std::unexpected(e)
map_errYesResult::map_errexpected::transform_error
Combinator setFull (and_then, or_else, etc.)FullPartial (and_then, or_else, transform)
transpose()YesYesNo
Void specializationYes (Result<void, E>)Yes (Result<(), E>)Yes (expected<void, E>)
C++ standardC++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() or if (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

  1. 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).

  2. 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 proven is_some() structurally (e.g. after if (o)).
  3. Combinators are consuming where Rust is consuming. filter(), ok_or(), ok_or_else(), and unwrap_or_else() are rvalue-qualified (&&), matching Rust's self semantics. This prevents accidental use-after-move and makes ownership transfer explicit in the type system.

  4. Option<T&> is a first-class specialization. sizeof(Option<T&>) == sizeof(T*). It's rebindable (unlike real C++ references), supports most combinators, and replaces raw T* with "might be null" semantics everywhere.

  5. Bridge to Result. ok_or(err) and ok_or_else(fn) convert Option<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 {
        &lt;&lt;tag&gt;&gt;
    }
    class Some {
        &lt;&lt;factory&gt;&gt;
    }
    Option ..> None : "constructed from"
    Option ..> Some : "constructed via"

The two variants at a glance:

Option<T>Option<T&>
StorageInline aligned_storageRaw pointer T*
Sizesizeof(T) + paddingsizeof(T*)
Owns valueYesNo
RebindableVia operator=Via operator=
CombinatorsAllAll except ok_or, unwrap_or_else

API Reference

Construction

ExpressionResult
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

MethodReturnsOn None
is_some()bool—
is_none()bool—
operator bool()bool (explicit)—

Unwrap

MethodReturnsOn 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

MethodSignatureSemantics
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.
  • filter is const-qualified (not rvalue-only) — references are trivially copyable.
  • take() returns Option<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 checkis_some() / is_none()has_value()is_some() / is_none()
Unwrapunwrap() — always checksvalue() — throws bad_optional_accessunwrap() — panics
Uncheckedunwrap_unchecked()operator* — UBunwrap_unchecked() — UB
Mapmap(fn)transform(fn) (C++23)map(fn)
AndThenand_then(fn)and_then(fn) (C++23)and_then(fn)
OrElseor_else(fn)or_else(fn) (C++23)or_else(fn)
Filterfilter(pred)✗filter(pred)
Inspectinspect(fn)✗inspect(fn)
OkOrok_or(e)✗ok_or(e)
Nullable refOption<T&>✗Built into borrow checker
Size (ref)sizeof(T*)N/AN/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() returns Result on invalid input.
  • Code point iteration. chars() yields char32_t, decoding multi-byte sequences transparently.
  • Dual OOM API. push() asserts on OOM; try_push_str() returns Result<void, AllocError>.
  • Option returns. pop() returns Option<char32_t> — no UB on empty strings.
  • Vec<uint8_t> storage. Shares the same allocator protocol as Vec<T>, enabling split_off(), retain(), shrink_to_fit(), and try_reserve().

Design Philosophy

  1. Valid UTF-8 is a type-level invariant. The constructor validates; push(), insert(), and pop() preserve it. There is no way to get invalid bytes into a String without from_utf8_unchecked() (which is call-by-call UB).

  2. Byte storage, code point interface. Internally Vec<uint8_t>, externally char32_t. The type system enforces the boundary — as_bytes() gives raw bytes, chars() gives decoded code points.

  3. Substring on code point boundaries. substr(), truncate(), split_off(), insert(), and remove() assert that offsets land on code point boundaries — never slicing a multi-byte character in half.

  4. 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/unicode extension.

  5. C++11, header-only. No <string> dependency — Vec<uint8_t> replaces std::vector<uint8_t>. No requires, consteval, or if 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

ExpressionResult
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 / MoveDefault — deep copy or ownership transfer

Views

MethodReturnsNotes
as_bytes()Span<const uint8_t>O(1), no copy
into_bytes()Vec<uint8_t>Consuming, O(1) move

Length / Capacity

MethodReturnsNotes
len()size_tByte count, O(1)
char_len()size_tCode point count, O(n)
empty()boollen() == 0
capacity()size_tAllocated byte capacity, O(1)
reserve(n)voidAssert on OOM
try_reserve(n)Result<void, AllocError>Explicit error
shrink_to_fit()voidAssert on OOM
try_shrink_to_fit()Result<void, AllocError>Explicit error

Element Access

MethodReturnsOn Out-of-Bounds / Empty
pop()Option<char32_t>Returns None if empty
substr(offset, count)StringAsserts on CP boundaries
chars()CharsCode point iterator

Mutation

MethodReturnsNotes
push(cp)voidEncodes 1–4 bytes, asserts on OOM + invalid CP
push_str(s)voidByte append, asserts on OOM
try_push_str(s)Result<void, AllocError>Explicit OOM
push_str("hi")voidC-string convenience
insert(byte_pos, cp)voidO(n), CP boundary assert
insert_str(byte_pos, s)voidO(n)
remove(byte_pos)char32_tO(n), CP boundary assert
truncate(new_len)voidCP boundary assert
clear()voidPreserves capacity
split_off(byte_pos)StringO(tail length), CP boundary assert
MethodReturnsNotes
find(pattern)Option<size_t>Byte-level memmem, O(n*m)
rfind(pattern)Option<size_t>Reverse scan
contains(pattern)boolfind(p).is_some()
starts_with(prefix)boolPrefix match
ends_with(suffix)boolSuffix match

Utility

MethodReturnsNotes
replace(from, to)StringAll occurrences, returns new String
replacen(from, to, n)StringCapped count
repeat(n)StringConcatenate n times
trim()StringRemove ASCII whitespace (0x09–0x0D, 0x20)
trim_start()StringLeading whitespace only
trim_end()StringTrailing whitespace only
retain(pred)voidKeep CPs where pred(cp) is true

Comparison

MethodNotes
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::Stringstd::stringRust String
EncodingGuaranteed UTF-8Byte string (any encoding)Guaranteed UTF-8
Internal storageVec<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_tN/A (manual decoding)Chars iterator → char
Validationfrom_utf8() → ResultNeverType-level guarantee
OOM handlingDual API: assert / ResultThrows std::bad_allocAborts
split_off()Yes (reuses Vec::split_off)NoYes
retain()Yes (code-point level)erase(remove_if(...))Yes
trim()ASCII-only (0x09-0x0D, 0x20)N/AUnicode whitespace
C++ standardC++11C++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

ExpressionDescription
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

MethodDescription
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)

MethodReturns
get<T>()T& / const T& / T&&
get<N>()Reference to the N-th type

Access (unchecked — debug assert only)

MethodReturns
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
StandardC++11C++17—
Empty stateNonevalueless_by_exception possibleNone
Accessget<T>() / get<N>()std::get<T>() / std::get<N>()Pattern matching
Error on wrong typePanicstd::bad_variant_accessCompile-time
VisitNot exposed (internal only)std::visitmatch
Duplicate typesInPlaceIndex<N> disambiguationstd::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() return Result<void, AllocError> for explicit error handling.
  • first() / last() return Option<T&>. No undefined behavior on empty containers — None is a first-class answer.
  • pop() returns Option<T>. Moving the last element out, not just destroying it. The value is consumed, not lost.
  • Allocator-aware. Accepts an optional Alloc template parameter using xpp's allocator protocol (allocate / deallocate / grow / shrink). EBO via CompressedPair means sizeof(Vec<T, GlobalAllocator>) = 24 bytes (3 words).

Design Philosophy

  1. 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 explicit try_* form that returns Result<void, AllocError>. Callers choose their trust level — crash-fast in debug, or handle gracefully in production paths.

  2. Option for nullable access. get(i), first(), last(), and pop() all return Option. 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, use get() when index validity is uncertain.

  3. 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 uses default_grow() (allocate + memcpy + deallocate) which the allocator may override with an in-place realloc.

  4. 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.

  5. C++11 compatible, header-only. No requires, consteval, or if constexpr. Template parameter defaults and CompressedPair enable 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 {
        &lt;&lt;template param&gt;&gt;
        +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

ExpressionResult
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

MethodReturnsNotes
len()size_tNumber of live elements
capacity()size_tAllocated slots (>= len)
empty()boollen() == 0
reserve(n)voidAsserts on OOM
try_reserve(n)Result<void, AllocError>Allocates space for len() + n elements
shrink_to_fit()voidAsserts on OOM
try_shrink_to_fit()Result<void, AllocError>Releases excess capacity

Element Access

MethodReturnsOn 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

MethodReturnsNotes
push(v)voidCopy, asserts on OOM
push(T&& v)voidMove, 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)voidDebug-asserts len < cap; no grow
pop()Option<T>Returns None if empty; destructs element
clear()voidDestructs all elements, len = 0, preserves capacity
truncate(n)voidDestructs elements [n, len), len = n

Bulk Operations

MethodReturnsNotes
resize(n, fill)voidAsserts on OOM
try_resize(n, fill)Result<void, AllocError>Grow: construct fill; shrink: truncate
append(other)voidMoves all elements from other, leaves it empty
try_append(other)Result<void, AllocError>Explicit error variant
split_off(at)VecMoves [at, len) into a new Vec, truncates this
swap_remove(i)TReplaces [i] with last element, returns old [i]; O(1)
retain(pred)voidKeeps elements where pred(x) is true; preserves order

Iteration

MethodReturns
begin()T*
end()T* (one past last element)
begin() constconst T*
end() constconst T*

Iterators are raw pointers — compatible with C++11 range-for and STL algorithms.

Allocator Access

MethodReturns
allocator()Alloc&
allocator() constconst 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 OOMXPP_ASSERTThrows std::bad_allocAborts
Explicit OOMtry_push() → Resulttry_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 storageEBO (CompressedPair)EBO (implementation-defined)Global only
Growth strategyDouble, min 42x or 1.5x (impl-defined)Double, min 4
swap_removeYes (O(1) unordered)NoYes
split_offYesNoYes
retainYeserase(remove_if(...), ...)Yes
Iterator typeRaw T*Wrapper classRaw pointer or slice iter
C++ standardC++11C++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:

  1. Allocate new buffer (allocator.allocate(new_layout))
  2. memcpy old elements to new buffer
  3. 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 via const-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

← libxpp

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 — wraps libx/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&) writes T through S&.
  • Deserialize<T>::run(D&) reads a T from D&.
  • 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 from expr if it's Err. XPP_SERDE_TRY_VAR(name, expr) also captures the Ok value.
  • The Visitor struct's visit_map receives a MapAccess& — call next_key() (returns Option<String>, None at end) and next_value<T>() (returns Result<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).

AttributeSerializeDeserialize
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

StrategyJSON shapeWhen to useMacro
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:

ScenarioErrorKind
Required field missing in JSON objectMissingField
Unknown variant tag (e.g. "triangle" when only circle/square declared)UnknownField
Type mismatch (e.g. expecting i32, got string)InvalidValue
Truncated binary inputEof
NaN/Inf in f64InvalidValue

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

FilePurpose
libxpp/xpp/serde/serde.hTraits, dispatchers, primitive specializations, concept docs
libxpp/xpp/serde/error.hErrorKind + Error
libxpp/xpp/serde/json.hJSON backend
libxpp/xpp/serde/bin.hBinary backend
libxpp/xpp/serde/macros.hXPP_SERDE + XPP_ENUM_SERDE macros

JSON Backend

← serde

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:

MethodInput
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++ typeJSON
booltrue / false
int32_t / int64_tnumber
uint32_t / uint64_tnumber
float / doublenumber (NaN/Inf rejected on serialize)
xpp::String"..."

Composite types

C++ typeJSON
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

StrategyJSON 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:

ScenarioErrorKindExample
Malformed JSON at parse timeInvalidValuefrom_string("{bad}")
Type mismatchInvalidValueexpecting i32, got "hello"
Required field missingMissingField{"name":"X"} into a struct requiring age
Unknown variant tagUnknownField{"triangle":{...}} when only circle/square declared
NaN/Inf in f64InvalidValueserialize(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

← serde

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:

MethodInputOwnership
Deserializer::from_bytes(const Vec<uint8_t>&)VecCopies the bytes into the deserializer
Deserializer::from_bytes(const uint8_t*, size_t)Raw pointer + lengthCopies the bytes
Deserializer::borrow(Span<const uint8_t>)SpanBorrows — 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

TypeEncodingSize
bool0x00 (false) / 0x01 (true)1 byte
i32 / u32little-endian4 bytes
i64 / u64little-endian8 bytes
f32IEEE 754 LE4 bytes
f64IEEE 754 LE8 bytes
Stringu32 length + UTF-8 bytes (no NUL terminator)4 + N
Option::None0x001 byte
Option::Some(v)0x01 + value1 + sizeof(v)
Vec<T>u32 count + count × element4 + Σ
structfield values back-to-back, no namesΣ
Enum (external)u32 tag_index + payload4 + 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:

ChangeCompatible?Notes
Add field at the endNo (old data is shorter)Reader expects the field, hits Eof.
Add field at the end + old readerYesOld reader stops after existing fields; new field ignored.
Remove fieldNoReader expects it, data is misaligned.
Reorder fieldsNoBinary is positional.
Change field typeNoWidth/encoding mismatch.
Add Option<T> field at endPartialOld 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:

ScenarioErrorKind
Truncated input (ran out of bytes mid-field)Eof
Invalid UTF-8 in StringInvalidValue
f64 is NaN/Inf on serializeInvalidValue
Option discriminator byte is neither 0x00 nor 0x01InvalidValue
Enum tag_index out of rangeInvalidValue

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

MacroDescriptionFallback
XPP_LIKELY(x)Branch expected true: __builtin_expect(!!(x), 1)(x)
XPP_UNLIKELY(x)Branch expected false: __builtin_expect(!!(x), 0)(x)

Function Attributes

MacroDescriptionFallback
XPP_NORETURN[[noreturn]]Compiler-specific or empty
XPP_FORCE_INLINEinline __attribute__((always_inline))inline
XPP_NOINLINE__attribute__((noinline))Empty

Control Flow

MacroDescriptionFallback
XPP_UNREACHABLE()__builtin_unreachable()std::abort()
XPP_FALLTHROUGH[[fallthrough]] (C++17) or __attribute__((fallthrough))((void)0)

Deprecation

MacroDescription
XPP_DEPRECATED(msg)[[deprecated(msg)]] (C++14) or __attribute__((deprecated(msg)))

Feature Detection

MacroDescription
XPP_DEBUG1 in debug (NDEBUG off), 0 in release. Override with -DXPP_DEBUG=.
XPP_HAS_COROUTINES1 if C++20 coroutines are available. Checks __cplusplus >= 202002L AND __cpp_coroutines >= 201902L. Override with -DXPP_HAS_COROUTINES=0/1.
XPP_FIBER1 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:

MacroWhen checkedUse case
XPP_PANIC(fmt, ...)AlwaysUnconditional 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:

  • NDEBUG not defined → XPP_DEBUG = 1 (Debug build)
  • NDEBUG defined → 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 the xEventLoop handle. 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

  1. 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.

  2. WaitScope is scope-tied, not a movable object. xEventLoopEnter/Leave form a stack-like pair. Moving the guard would leave the original scope without a corresponding Leave, violating the contract.

  3. The handle itself is thread-safe. stop(), wake(), and xEventLoopPost can be called from any thread. run(), xTimerStart, and xEventAdd must be called from the entered thread.

  4. EventLoop::current() panics outside WaitScope. This catches the common bug of calling Promise::await() without an active event loop binding.

  5. Fiber integration. With XPP_FIBER, PromiseContext::park() can suspend a fiber via xFiberYield() instead of blocking the thread with X_RUN_ONCE. The same WaitScope and EventLoop drive all fibers — no separate scheduler needed. See .await() docs.

API Reference

EventLoop

MemberDescription
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

ValueBehavior
RunMode::DefaultBlock until stop() or no more active handles.
RunMode::OnceSingle iteration, block until at least one event.
RunMode::NoWaitSingle iteration, non-blocking poll.

WaitScope

MemberDescription
WaitScope(const EventLoop&)Enter the loop. Binds it to the current thread.
~WaitScope()Leave the loop. Unbinds the thread.
Non-copyable, non-movableThe 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 + WaitScopeuv_loop_t + uv_run()asio::io_context
OwnershipRAII (move-only)Manual alloc/freeRAII (copyable or moveable)
Thread bindingExplicit via WaitScopeImplicit on first callExplicit via run()
Wake from another threadloop.wake()uv_async_sendpost()
Sizesizeof(void*) (opaque handle)~1KB structLarge (many members)
EmbeddabilityZero deps beyond libxlibuv neededBoost/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

← libxpp

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

  1. .await() First — .await() is the universal entry point. Outside a fiber it drives xEventLoopRun() directly. Inside a fiber (via xpp::fiber()) it suspends the fiber via xFiberYield() — non-blocking, stackful concurrency without co_await syntax.
  2. One-Shot Polling — poll() returns Option<T>: Some(value) = ready, None = pending. No separate take().
  3. Single-Threaded Executor — Like Tokio's current_thread runtime. The event loop is both reactor (I/O) and scheduler (timers/callbacks). No background thread pool needed.
  4. Auto-Flatten — .then(fn) returning Promise<U> becomes Promise<U>, not Promise<Promise<U>>.
  5. Lock-Free Cross-Thread Resolve — PromiseResolver holds ArcWeak; resolve() silently drops if Promise is destroyed.
  6. Void-Aware Templates — Void + FixVoid<T> maps void → Void for uniform generic code.
  7. Nested .await() Is Safe — WaitScope owns the loop binding; nested Run calls don't unbind it.
  8. 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.
  9. Coroutine-Native — Promise<T> is both a poll-based node container and a C++20 coroutine return type. co_await drives the same poll() mechanism as .then().
  10. 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&lt;T&gt;()"]
        PRR["PromiseResolver&lt;T&gt;"]
        CORO["co_await / co_return"]
    end

    subgraph "PromiseNode Hierarchy"
        BASE["PromiseNode&lt;T&gt;<br/>poll(waker) → Option&lt;T&gt;"]
        IMM["ImmediatePromiseNode"]
        TRANS["TransformPromiseNode"]
        CHAINP["ChainPromiseNode"]
        ADAPT["AdapterPromiseNode&lt;T, Adapter&gt;"]
        MANUAL["ManualResolveNode&lt;T&gt;"]
        COROP["CoroutinePromiseNode&lt;T&gt;"]
    end

    subgraph "Shared State"
        RS["ResolveState&lt;T&gt;<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

API Reference

Promise<T>

MemberDescription
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>

MemberDescription
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

FunctionDescription
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

← Promise<T>

.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
    }
}
  1. Poll — ask the promise node if the value is ready.
  2. 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).
  3. Repeat until poll() returns Some<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 inC++11 + fiberC++20
Blocks threadYes (if not in fiber)No (suspends coroutine)
Inside fiberSuspends fiber (non-blocking)N/A
Use casemain, tests, fibers, sync codeInside another coroutine
Mechanismpoll() + 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

← Promise<T>

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

← Promise

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

  • PromiseResolver can 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. Check operator bool() first.
  • Nested wait() is safe but beware deadlocks. If the inner promise is never resolved, wait() spins indefinitely.

Timers & Timeouts

← Promise

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() → sets resolved=true, wakes poller
  • Promise destroyed early → ~TimerAdapter() calls xTimerStop (if not yet fired, checked via m_fired atomic flag)
  • Loop destroyed → on_cancel callback nulls m_handle, sets m_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

← Promise

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> (not Promise<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>...) returns Promise<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: checks Option::is_some() per child
  • RacePromiseNode: returns on first Some, 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:

NodeDestructor behavior
TimerAdapterCalls xTimerStop if not yet fired
AdapterPromiseNodeDestroyed — PromiseResolver::resolve() safely drops via ArcWeak
ImmediatePromiseNodeNo cleanup needed
TransformPromiseNodeDestroys dependency chain

With ArcWeak-based PromiseResolver, destroying an AdapterPromiseNode is safe — resolve() finds upgrade() == None and silently drops. No UAF.

Comparison with Other Languages

FeaturexppJSRustfolly
allall(p1, p2) → tuplePromise.all → arraytry_join → tuplecollect → vector
racerace(p1, p2) → firstPromise.race → firstselect → firstany → first
Heterogeneous allYes (tuple)NoYes (tuple)No
Loser cleanupDestructorGCDropDestructor
Executor neededNoYesYesYes

Utilities: try_next

← Promise

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 return Promise<Result<T, E>> where Result has .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: the TryNext struct (with items and fn by value) moves through each .then() node in the Promise chain — no shared_ptr refcount overhead
  • Template operator() in Then: accepts Result<T, E> without spelling out the types, enabling duck-typing of any Result type with .is_ok()
  • Tail-recursive via Promise chain: return next() creates a new .then() link, rather than growing the call stack

Performance

AllocationCount
TryNext struct (items + fn)0 (stack/inline)
PromiseNode chain1 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

← Promise

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() calls upgrade().
  • When node is destroyed → strong count → 0 → upgrade() returns None → 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

← Promise

libxpp supports three ways to express async flows — pick the one that fits your compiler:

StyleCompilerBlocking?
.then() chainsC++11No (callback-driven)
.await() + fiberC++11 + XPP_FIBERNo (stackful suspend)
co_await / co_returnC++20No (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) — immediate
  • after(ms) — timer
  • work(fn) — thread pool
  • async<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> — polls PromiseNode<U>, stores result in Option<U>*
  • VoidAwaitState — polls PromiseNode<void>, sets bool* (because PromiseNode<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

← Promise

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 call
  • None = pending, waker stored for later notification
  • One-shot: once Some is returned, poll() must never be called again

Node Types

Node TypePurposepoll() 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 patternPolls ResolveState: check resolved → register waker → double-check
ManualResolveNode<T>async<T>() factorySame poll logic, no Adapter
YieldPromiseNodeyield()Returns Some(Void{})
AllTuplePromiseNode<Ts...>all() combinatorPolls all children, collects tuple when all done
AllVoidPromiseNode<N>all() all-voidCountdown, returns Some(Void{}) when all done
RacePromiseNode<T, N>race() combinatorReturns 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:

StateBitsMeaning
WAITING00Idle
REGISTERING01poll side storing waker
WAKING10resolve side waking
RACE11Both 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

ConsumerNode TypeReason
async<T>()ManualResolveNode<T>Deferred resolve (ArcWeak, safe after destruction)
Promise::resolve(v)ImmediatePromiseNodeImmediate 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 / ChainPromiseNodeTransform / auto-flatten
yield()YieldPromiseNodeChain entry point
all(...)AllTuplePromiseNode / AllVoidPromiseNodeConcurrent — wait for all
race(...)RacePromiseNodeConcurrent — 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
StylePromise-based (poll/wait)Callback-based
ModesOne-shot onlyOne-shot + repeating
Composition.then(), .await()None (just fires callback)
Use caseDelayed computation in a Promise chainPeriodic tasks, heartbeats, simple delayed callbacks

API Reference

Construction

ExpressionBehavior
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

MethodReturnsDescription
stop()voidCancel the timer. Idempotent.
start()boolResume after stop(). false if already active or no live loop.
is_active()boolTrue if timer is currently scheduled.
operator bool()boolEquivalent to is_active().
handle()xTimerUnderlying 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 the WaitScope thread.

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

  1. 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's xFsReq.offset.

  2. RAII with sync close — ~File() calls close(fd) synchronously if still open. Blocking but fast (close() is near-instant). Call close() explicitly for async close.

  3. Adapter pattern — Each operation (open, read, write, close, stat, mkdir, rmdir, unlink, rename) has a typed FsAdapter that bridges xFsReq callbacks to PromiseResolver<T>. Same lifecycle as TimerAdapter and WorkAdapter.

  4. Blocking I/O offloaded — read_all/write_all/sync_all/stat use xpp::work() to offload to the thread pool. Never blocks the event loop thread.

  5. Buffer overloads — C-style void* + size_t (base, matches POSIX, works with all buffer types) and Span<uint8_t> (type-safe). No buffer ownership — caller manages lifetime.

  6. Negative = error — read/write return Promise<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&lt;T, FsXxxAdapter&gt;"]
        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

MethodReturnsDescription
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)FileTake 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()intRaw file descriptor
is_open()boolTrue if handle valid

Free functions

FunctionReturnsDescription
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

FieldTypeDescription
sizeoff_tFile size in bytes (-1 = error)
modeintFile mode (st_mode)
mtimeuint64_tModification time (ms since epoch)
ctimeuint64_tChange 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

Featurexpp::fs::Filetokio::fs::Filestd::fstream
AsyncPromise + thread poolasync + thread poolblocking
Offsetexplicit (pread/pwrite)file cursor (seek)file cursor (seek)
Buffervoid* / Span<uint8_t>&mut [u8] / &[u8]char* / stream ops
RAII closesync close in dtorclose on drop (async)close on dtor
Errornegative ssize_tResult<T, io::Error>stream state bits
Directory opscreate_dir / remove_dir / renametokio::fs::create_dir etc.std::filesystem
Compose with async.then() / co_await / all() / race().await / tokio::join! / tokio::select!N/A
C++ standardC++11N/AC++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() as Promise<void>, fast-path read()/write().

  • I/O Error — io::Error: niche-optimized (4-byte) error type with ErrorKind, raw_os_error(), raw_xerrno(). Mirrors Rust's std::io::Error.

  • Utilities — read_all and copy: 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.

  • BufWriter

  • Take

  • Empty

  • Sink

  • Duplex

  • Simplex

  • Repeat

  • Join

  • Split — BufWriter<W>: buffered async writer. Coalesces small writes, explicit flush().

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

  1. Register once, not per operation — AsyncFd registers with xEventAdd once in the constructor. Operations check readiness bools first (fast path), only storing a PromiseResolver when EAGAIN occurs. No xEventAdd/xEventDel churn.

  2. Fast path: zero Promise overhead — read()/write() try the syscall immediately. If data is available (the common case), the result is returned via resolve(n) with no Promise chain, no event registration, no waiting.

  3. Adapter pattern, not custom PromiseNode — readable()/writable() use adapt<void, AsyncReadAdapter>(). The adapter stores a PromiseResolver<void> in AsyncFd's waiter slot. When on_event fires, it calls resolver.resolve(), which triggers the waker in ResolveState. Same pattern as TimerAdapter, WorkAdapter, FsOpenAdapter.

  4. Single-threaded — All operations run on the event loop thread. Plain bool for readiness, no atomics or mutex.

  5. Does not own fd — AsyncFd registers/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

MethodReturnsDescription
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()voidDeregister, wake pending waiters. Does NOT close fd
fd()intRaw file descriptor
is_closed()boolTrue after close() or move

Free functions

FunctionReturnsDescription
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

Featurexpp::io::AsyncFdtokio PollEvented / IoSource
Registrationonce (persistent)once (persistent)
Readiness trackingbool (single-thread)atomic + mutex
Wait mechanismPromiseResolver (Adapter)Waker (custom PromiseNode)
Thread safetysingle-threadmulti-thread
Fast pathtry syscall, zero overheadtry syscall, zero overhead
fd ownershipcaller ownscaller 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

VariantMeaning
InvalidInputMalformed input (bad address string, NULL arg)
HostNotFoundDNS resolution returned no results
AddrInUseEADDRINUSE
AddrNotAvailableEADDRNOTAVAIL
PermissionDeniedEACCES
ConnectionRefusedECONNREFUSED
ConnectionResetECONNRESET
BrokenPipeEPIPE
TimedOutETIMEDOUT
OtherOther syscall error

Error

MethodReturnsDescription
kind()ErrorKindCategorical kind
raw_os_error()intOS errno, or 0 if not from a syscall
raw_xerrno()xErrnolibx error code, or xErrno_Ok if not from libx
message()const char*Human-readable (strerror or kind description)
from_errno(e)ErrorConstruct from OS errno
from_kind(k)ErrorConstruct from ErrorKind
from_xerrno(e)ErrorConstruct 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 rangeSourceAccessor
0x00000001 .. 0x3FFFFFFFOS errnoraw_os_error()
0x40000000 .. 0x7FFFFFFFlibx xErrno (bit 30 set)raw_xerrno()
0x80000000 .. 0xFFFFFFFFCustom ErrorKind (negative)kind()
0x00000000Niche (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

MethodReturnsDescription
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

MethodReturnsDescription
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

MethodReturnsDescription
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_tBytes 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

MethodReturnsDescription
empty()EmptyFactory 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

MethodReturnsDescription
sink()SinkFactory 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

MethodReturnsDescription
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()voidClose 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

MethodReturnsDescription
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()voidSignal 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

MethodReturnsDescription
repeat(byte = 0)RepeatCreate 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

MethodReturnsDescription
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()voidForward 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

MethodReturnsDescription
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:

ChannelPatternCapacityUse Case
oneshot1→1, single-use1Deferred result, async callback
mpscM→1bounded / unboundedWork queues, task dispatch
broadcastM→NboundedEvent fan-out, shutdown signals
watchM→N1 (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

TypeMethodDescription
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:

VariantAPIBackend
Boundedchannel<T>(cap)Lock-free ring buffer (pre-allocated slots)
Unboundedchannel<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>

MethodReturnsDescription
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>

MethodReturnsDescription
recv()Promise<Option<T>>Async receive. none when closed & empty.
try_recv()Result<T, TryRecvError>Sync receive.

Unbounded UnboundedSender<T>

MethodReturnsDescription
send(T)voidAlways succeeds.
try_send(T)boolAlways returns true.

Unbounded UnboundedReceiver<T>

MethodReturnsDescription
recv()Promise<Option<T>>Async receive.
try_recv()Option<T>Sync receive.

Thread safety

  • Bounded: lock-free send path (fetch_add CAS). Multiple threads can try_send concurrently.
  • Unbounded: lock-free linked list. Multiple threads can send concurrently.
  • 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>

MethodReturnsDescription
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_tNumber of active receivers.
len()size_tNumber of buffered values.

Receiver<T>

MethodReturnsDescription
recv()Promise<Result<T, RecvError>>Next value, or Lagged/Closed.
try_recv()Result<T, TryRecvError>Synchronous receive.

Error types

TypeVariantDescription
RecvErrorLaggedValues were evicted before reading.
ClosedAll senders dropped, buffer empty.
TryRecvErrorEmptyNo value available.
ClosedChannel empty and closed.
SendError<T>NoReceiver(v)No receivers subscribed; value returned.

Thread safety

  • Lock-free send path (mutex on m_head/m_tail update 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>

MethodReturnsDescription
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_tNumber of active receivers.
is_closed()boolWhether channel is closed.
closed()Promise<void>Resolves when all receivers dropped.

Receiver<T>

MethodReturnsDescription
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.

MethodReturnsDescription
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

MethodReturnsDescription
notified()Promise<void>Wait for the next notification.
notify_one()voidWake one waiting coroutine.
notify_waiters()voidWake 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

  1. Promise-based, poll-driven — All async ops return Promise<T>. wait() drives the event loop; no separate runtime or reactor thread.

  2. Fast-path syscall + EAGAIN readiness — recv/send/recv_from/send_to try the syscall immediately. On EAGAIN, they wait for readiness via AsyncFd and retry. Zero Promise overhead when data is available.

  3. adapt() for one-shot ops — lookup_host() uses adapt<T, Adapter>() — the adapter starts the async op in its constructor, cancels in its destructor, and the AdapterPromiseNode owns the adapter. TcpStream::connect() uses async() + new (self-deleting adapter) because libx's xTcpConnect has no cancel API.

  4. TLS is transparent — Pass Option<const TlsContext&> = none to TcpStream::connect() to enable TLS. libx's xTcpConnect does the handshake; the resulting TcpStream transparently encrypts/decrypts. No separate TlsConn type.

  5. RAII everywhere — TcpStream, TcpListener, UdpSocket, Url, TlsContext all close/free their underlying resources in destructors. Move-only.

  6. C++11-compatible — All headers compile as C++11. std::pair + std::tie for recv_from results (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 — TcpStream and TcpListener: 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 around xUrl with structured errors.
  • TLS — TlsConfig and TlsContext: RAII TLS configuration.

bind methods return Promise<io::Result<T, io::Error>> — see I/O Error for the error type.

Comparison with tokio::net

Aspectxpp::nettokio::net
Async modelPoll-based Promise + wait()async fn + .await
TCP connectPromise<TcpStream> (async()+new)Future<Result<TcpStream>>
ReadinessAsyncFd (edge-triggered)mio (edge-triggered)
Fast path::read + EAGAIN → readinessread + EAGAIN → readiness
TLSOption<const TlsContext&> to connect()TlsConnector::connect()
DNSlookup_host() → Promise<vector<SocketAddr>>lookup_host() → Future<impl Iterator>
UDPrecv_from → Promise<pair<ssize_t, SocketAddr>>recv_from → Future<Result<(usize, SocketAddr)>>
BindAsync (Promise<io::Result<T>>, DNS for hostnames)Async (ToSocketAddrs may resolve)
Error typeio::Error (4 bytes, niche-optimized)std::io::Error (heap-allocated)
ThreadingSingle-threadedMulti-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

MethodReturnsDescription
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_tSync non-blocking read, -1/EAGAIN if none
try_write(buf, len)ssize_tSync 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()intGet & 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()voidClose + deregister
is_open()boolConnection 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

MethodReturnsDescription
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()voidStop listening
is_open()boolListener 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 — xTcpConnect has 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 via ArcWeak.

  • TcpListener uses shared_ptr — xTcpListenerCreate stores a void* arg pointer. The Impl struct is heap-allocated and stable, so moves don't dangle the callback arg.

  • Buffer lifetime — buf pointers passed to recv/send must 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

MethodReturnsDescription
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)xErrnoConnect 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_tSync non-blocking recv
try_send(buf, len)ssize_tSync non-blocking send
try_recv_from(buf, len)pair<ssize_t, Option<Addr>>Sync non-blocking recvfrom
try_send_to(buf, len, target)ssize_tSync 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()intGet & 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()voidClose + deregister
is_open()boolSocket 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. UdpSocket uses ::socket() + ::bind() + AsyncFd directly.
  • Buffer lifetime — buf pointers passed to recv_from/send_to must remain valid until the returned Promise resolves.
  • Bind with DNS — bind("host:port") tries SocketAddr::parse first (literal IP). If that fails, it splits on the last : and calls lookup_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

FunctionReturnsDescription
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 xDnsResult with an error field. On error, the adapter resolves with an empty vector (not a rejection). Callers should check addrs.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

MethodReturnsDescription
parse(raw)Result<Url, UrlParseError>Parse a URL string
parse(std::string)Result<Url, UrlParseError>Parse from std::string
scheme()std::stringe.g. "https"
host()std::stringe.g. "example.com"
port_num()uint16_tExplicit or scheme default (http=80, https=443)
path()std::stringe.g. "/api"
query()std::stringe.g. "q=1"
fragment()std::stringe.g. "section1"
userinfo()std::stringe.g. "user:pass"
raw()const xUrl&Underlying libx handle

UrlParseError

VariantMeaning
EmptyInput was NULL or empty
InvalidFormatNot 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

MethodReturnsDescription
client()TlsConfigClient defaults (system CA, verify on)
client_insecure()TlsConfigClient that skips peer verification
server(cert, key)TlsConfigServer config with cert + key paths
server(cert, key, ca)TlsConfigServer 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_*(...) & → returns TlsConfig&, modifies in-place (lvalue chain)
  • with_*(...) && → returns TlsConfig&&, 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

MethodReturnsDescription
TlsContext(conf)TlsContextCreate context from TlsConfig
TlsContext(xTlsConf*)TlsContextCreate from raw libx config
reload(conf)intHot-reload certificates (0 = success)
raw()xTlsCtxUnderlying libx context
is_valid()boolConstruction succeeded
operator bool()boolSame 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:

KindProducerConsumer
EmptyBody::empty()EOF immediately
OnceBody::from(bytes / string / Vec)one-shot bytes
ChannelBody::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

Client

← HTTP

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 with bytes() / 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 (a Protocol error whose status() 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.

Server

← HTTP

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 typeMeaning
Responsesynchronous 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. See issues/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

  • Server is move-only; the destructor tears down the C server.
  • In-flight handlers: a handler may complete after the Server is destroyed. The spawn chain captures a ServerLifetime Arc and drops the response write instead of touching the freed ctx (test DestroyWithInflightHandlerDoesNotCrash).
  • serve() blocks nothing — run the loop as usual; stop() resolves it.

Body

← HTTP

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:

KindConstructionRead behavior
EmptyBody::empty()immediate EOF (read() returns 0)
OnceBody::from(bytes / Vec<uint8_t> / String / const char*)one-shot bytes
ChannelBody::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 the Body in a named variable while awaiting (unlike bytes()/text(), which move it into an Arc internally).

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.

  • 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

LibraryDescription
xbaseCore primitives — event loop, timers, tasks, async sockets, memory, lock-free data structures
xbufBuffer primitives — linear, ring, and block-chain I/O buffers
xcryptoCryptographic primitives — SHA-1, SHA-256 (OpenSSL / mbedTLS / builtin), MD5, CRC-32, HMAC, UUID (v4/v5/v7)
xnetNetworking primitives — URL parser, async DNS resolution, TCP, shared TLS configuration types
xlogAsync logging — MPSC queue, timer/pipe flush, log rotation
xhttpAsync HTTP client & server — libcurl multi-socket client with SSE streaming, HTTP/1.1 & HTTP/2 async server with TLS, WebSocket server & client
xdnsAsync DNS client & server — protocol-native resolver over UDP with TTL caching, bitmask queries, authoritative zones, forwarding, and query filtering
xp2pP2P connectivity — ICE agent, STUN/TURN client, SDP codec, NAT traversal
xfsAsync 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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

HeaderDocumentDescription
event.hevent.mdCross-platform event loop (edge-triggered) — kqueue / epoll / poll backends with built-in timer and thread-pool integration
timer.htimer.mdMonotonic timer with push (thread-pool) and poll (lock-free MPSC) fire modes
task.htask.mdN:M task model — lightweight tasks multiplexed onto a configurable thread pool
socket.hsocket.mdAsync socket abstraction with idle-timeout support over xEventLoop
memory.hmemory.mdReference-counted allocation with vtable-driven lifecycle (ctor/dtor/retain/release)
slab.hslab.mdFixed-size object pool — single-threaded xSlab and thread-safe xSlabMt variants for high-frequency small allocations
log.hlog.mdPer-thread callback-based logging with optional backtrace on fatal
backtrace.hbacktrace.mdPlatform-adaptive stack trace capture (libunwind > execinfo > stub)
error.herror.mdUnified error codes (xErrno) and human-readable messages
heap.hheap.mdGeneric min-heap with O(log n) insert/remove, used internally by the timer subsystem
map.hmap.mdGeneric key-value map with three backends: hash table, flat table, and red-black tree
mpsc.hmpsc.mdLock-free multi-producer / single-consumer intrusive queue
atomic.hatomic.mdCompiler-portable atomic operations (GCC/Clang __atomic builtins)
io.hio.mdAbstract I/O interfaces (Reader, Writer, Seeker, Closer) with convenience helpers (xReadFull, xReadAll, xWritev, etc.)
list.hlist.mdIntrusive doubly-linked circular list — zero-allocation, inline implementation derived from Linux kernel's list.h
array.harray.mdGeneric auto-growing array — type-erased contiguous storage with optional lifecycle callbacks (retain/release/equal)
arena.harena.mdFixed-capacity bump allocator — O(1) allocation, O(1) ownership check, no per-object free; ideal for phase-scoped data (parse trees, request buffers)
hex.hhex.mdHex (base16) encode/decode — binary to/from ASCII hex string (lower-case output, case-insensitive decode)
base64.hbase64.mdBase64 encode/decode (RFC 4648) — standard and URL-safe alphabets, with or without = padding
random.hrandom.mdCross-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.hcmd.mdAsync command executor over xEventLoop — spawn child processes with stdout/stderr capture, streaming, discard, and PTY modes
flag.hflag.mdPOSIX/GNU-style command-line flag parser — typed storage, auto-generated --help, choice validation, counter and positional support
fiber.hfiber.mdCross-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 descriptorsevent.h — register fds and get edge-triggered callbacks
Schedule delayed or periodic worktimer.h — standalone timer, or use xEventLoopTimerAfter() for event-loop-integrated timers
Run CPU-bound work off the main threadtask.h — submit to a thread pool, optionally collect results
Post a callback to the event loop from another threadevent.h — xEventLoopPost() for zero-overhead cross-thread dispatch
Manage non-blocking TCP/UDP connectionssocket.h — wraps socket + event loop + idle timeout
Allocate objects with automatic cleanupmemory.h — XMALLOC(T) + xRetain/xRelease
Pool many small fixed-size objects with minimal overheadslab.h — xSlab (ST) / xSlabMt (MT) object pool with intrusive freelist
Allocate many objects with a shared lifetime and free them all at oncearena.h — xArena bump allocator; one xArenaDestroy() or xArenaReset() reclaims everything
Report errors from library internalslog.h — thread-local callback, or stderr fallback
Capture a stack trace for debuggingbacktrace.h — xBacktrace() fills a buffer
Handle error codes uniformlyerror.h — xErrno enum + xstrerror()
Build a priority queueheap.h — generic min-heap with index tracking
Store key-value pairs with O(1) or O(log n) accessmap.h — generic map with hash, flat, and tree backends
Chain elements in an intrusive doubly-linked listlist.h — zero-allocation circular list with xContainerOf entry access
Store a growable list of fixed-size elements with automatic cleanuparray.h — xArray with optional retain/release callbacks for per-element resource management
Pass messages between threads lock-freempsc.h — intrusive MPSC queue
Perform atomic read-modify-writeatomic.h — macro wrappers over compiler builtins
Get current time in millisecondstime.h — xMonoMs() for elapsed time, xWallMs() for wall-clock
Read/write through abstract I/O interfacesio.h — xReader / xWriter + helpers like xReadFull, xReadAll
Submit a shell command asynchronouslycmd.h — xCommandExecutorSubmit() with capture, stream, or discard output modes
Parse command-line argumentsflag.h — xFlagAddString / Int / Bool / Choice / Counter / Positional + xFlagParse with auto-generated --help
Yield and resume execution contexts cooperativelyfiber.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. xIOBuffer uses xbase's atomic.h for 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) and atomic.h for the cancellation flag. Cross-thread notifications (e.g., ICE/TURN completions) can use xEventLoopPost() 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

  1. Edge-Triggered Everywhere — All three backends operate in edge-triggered mode. kqueue uses EV_CLEAR, epoll uses EPOLLET, and poll emulates edge-triggered behavior by clearing the event mask after each notification (requiring the caller to re-arm via xEventMod()). This design encourages callers to drain fds completely, reducing spurious wakeups.

  2. 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.

  3. 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.

  4. 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 via xWorkCancel() if it hasn't started yet.

  5. 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.

  6. Self-Pipe Trick for Signals — On epoll and poll backends, signal delivery uses the self-pipe trick (a sigaction handler writes to a pipe) rather than signalfd, avoiding the fragile requirement of blocking signals in every thread. On kqueue, EVFILT_SIGNAL is used natively.

  7. Named Loop → Named Thread — xEventLoopEnter() sets the calling thread's OS name (via pthread_setname_np) to the loop's configured name, making loops visible in ps, htop, and debuggers. The name is restored from the previous loop on xEventLoopLeave(). The default is "xEventLoop" — override via xEventLoopConf.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

TypeDescription
xEventMaskBitmask enum: xEvent_Read (1), xEvent_Write (2), xEvent_Timeout (4)
xEventFuncvoid (*)(int fd, xEventMask mask, void *arg) — I/O callback
xTimerFuncvoid (*)(void *arg) — Timer callback
xSignalFuncvoid (*)(int signo, void *arg) — Signal callback
xWorkDoneFuncvoid (*)(void *arg, void *result) — Offload completion callback
xEventLoopPostFuncvoid (*)(void *arg) — Posted callback (via xEventLoopPost)
xEventLoopOpaque handle to an event loop
xEventSourceOpaque handle to a registered event source
xTimerOpaque handle to a builtin timer
xWorkOpaque handle to a submitted offload work item

Functions

Lifecycle

FunctionSignatureThread Safety
xEventLoopCreatexEventLoop xEventLoopCreate(void)Not thread-safe
xEventLoopCreateWithConfxEventLoop xEventLoopCreateWithConf(const xEventLoopConf *conf)Not thread-safe
xEventLoopCreateWithGroupxEventLoop xEventLoopCreateWithGroup(xTaskGroup group)Not thread-safe
xEventLoopDestroyvoid xEventLoopDestroy(xEventLoop loop)Not thread-safe
xEventLoopRunint xEventLoopRun(xEventLoop loop, int mode)Not thread-safe (call from one thread)
xEventLoopStopvoid xEventLoopStop(xEventLoop loop)Thread-safe
xEventLoopEntervoid xEventLoopEnter(xEventLoop loop)Not thread-safe
xEventLoopLeavevoid xEventLoopLeave(void)Not thread-safe
xEventLoopCurrentxEventLoop xEventLoopCurrent(void)Thread-safe
xEventLoopGlobalxEventLoop xEventLoopGlobal(void)Not thread-safe
xEventLoopFdint xEventLoopFd(xEventLoop loop)Not thread-safe
xEventLoopNextTimeoutint xEventLoopNextTimeout(xEventLoop loop)Not thread-safe

I/O Sources

FunctionSignatureThread Safety
xEventAddxEventSource xEventAdd(int fd, xEventMask mask, xEventFunc fn, void *arg)Not thread-safe
xEventModxErrno xEventMod(xEventSource src, xEventMask mask)Not thread-safe
xEventDelxErrno xEventDel(xEventSource src)Not thread-safe

Timers

FunctionSignatureThread Safety
xTimerStartxTimer xTimerStart(xTimerFunc fn, void *arg, xTimerFunc on_cancel, uint64_t timeout_ms, uint64_t repeat_ms)Not thread-safe
xTimerStopxErrno xTimerStop(xTimer timer)Thread-safe

Cross-Thread

FunctionSignatureThread Safety
xEventLoopWakexErrno xEventLoopWake(xEventLoop loop)Thread-safe (signal-handler-safe)
xEventLoopPostxErrno xEventLoopPost(xEventLoop loop, xEventLoopPostFunc fn, void *arg)Thread-safe
xWorkSubmitxWork xWorkSubmit(xTaskGroup group, xTaskFunc work_fn, xWorkDoneFunc done_fn, void *arg)Thread-safe
xWorkCancelxErrno xWorkCancel(xWork work)Thread-safe

Signal

FunctionSignatureThread Safety
xSignalxErrno xSignal(int signo, xSignalFunc fn, void *arg)Not thread-safe

Run Modes

ConstantValueDescription
X_RUN_DEFAULT-1Block until xEventLoopStop() or no active handles
X_RUN_ONCE-2Single iteration, block until at least one event
X_RUN_NOWAIT-3Single 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

  1. Network Servers — Register listening sockets and accepted connections with the event loop. Use edge-triggered callbacks to read/write data without blocking. Combine with xSocket for idle-timeout support.

  2. 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.

  3. 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. Use xWorkCancel() to cancel pending work when the associated resource is being released.

  4. 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 EAGAIN in 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() over xWorkSubmit() 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. Pass X_RUN_DEFAULT for indefinite blocking, X_RUN_ONCE for a single blocking iteration, or X_RUN_NOWAIT for non-blocking poll. For tests, pump the loop manually with X_RUN_ONCE in a loop with a timeout counter.
  • Cancel offloaded work when releasing resources. If you submit work via xWorkSubmit() and the associated resource (passed as arg) is about to be freed, use xWorkCancel() 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 — let done_fn handle 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

Featurexbase event.hlibeventlibevlibuv
Trigger ModeEdge-triggered onlyLevel (default), edge optionalLevel + edgeLevel-triggered
Backendskqueue, epoll, pollkqueue, epoll, poll, select, devpoll, IOCPkqueue, epoll, poll, select, portkqueue, epoll, poll, IOCP
Timer IntegrationBuilt-in min-heapSeparate timer APIBuilt-inBuilt-in
Thread PoolBuilt-in (xEventLoopSubmit)None (external)None (external)Built-in (uv_queue_work)
Signal HandlingSelf-pipe / EVFILT_SIGNALevsignalev_signaluv_signal
API StyleOpaque handles, C99Struct-based, C89Struct-based, C89Handle-based, C99
Binary Size~15 KB~200 KB~50 KB~500 KB
DependenciesNoneNoneNoneNone
Windows SupportNot yetYes (IOCP)Yes (select)Yes (IOCP)
Design GoalMinimal building blockFull-featured frameworkMinimal + performantCross-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

BenchmarkTime (ns)CPU (ns)Iterations
BM_EventLoop_CreateDestroy700700974,157
BM_EventLoop_WakeLatency4134131,717,088
BM_EventLoop_PipeAddDel1,1441,144612,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

DimensionlibxlibuvRatio
Wake Latency413 ns417 nsTied (libx 1.01× faster)
Timer (single)461 ns1,517 nslibx 3.3× faster
Timer (×1000)43,545 ns68,659 nslibx 1.6× faster
Offload (single)3,785 ns3,449 nslibuv 1.1× faster (tied)
Offload (×1000)456,426 ns218,513 nslibuv 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:

FileBackendTrigger ModeSelection
event_kqueue.ckqueueEV_CLEAR (native edge)#ifdef X_HAS_KQUEUE
event_epoll.cepollEPOLLET (native edge)#ifdef X_HAS_EPOLL
event_poll.cpoll(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_USER on kqueue, eventfd on epoll, pipe on poll) with atomic coalescing
  • A min-heap for builtin timers (protected by timer_mu mutex)
  • 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:

BackendMechanismFds Used
kqueueEVFILT_USER with NOTE_TRIGGER0 (kernel event, no fd)
epolleventfd (EFD_NONBLOCK | EFD_CLOEXEC)1 (wake_rfd)
pollNon-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

BackendMechanism
kqueueEVFILT_SIGNAL with EV_CLEAR — native kernel support
epollSelf-pipe trick: sigaction handler writes to a per-signal pipe
pollSelf-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

  1. Minimal Surface — Seven functions. No scheduler, no message passing, no preemption. Fibers yield voluntarily via xFiberSwitch() or xFiberYield(). Higher-level scheduling is the caller's responsibility (e.g., event loop + waker integration).

  2. Independent Stacks — Each fiber gets its own stack with a guard page (PROT_NONE on Unix, OS-managed on Windows). Stack overflow triggers SIGSEGV / access violation deterministically instead of corrupting adjacent memory.

  3. 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.

  4. 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).

  5. 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

TypeDescription
xFiberOpaque handle. Represents either a main fiber (thread) or a child fiber.
xFiberProctypedef void (*xFiberProc)(void *arg). Fiber entry point.

Functions

FunctionSignatureDescription
xFiberMainxFiber xFiberMain(void)Convert the current thread. Idempotent. Returns the main fiber handle.
xFiberCreatexFiber 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().
xFiberDestroyvoid xFiberDestroy(xFiber fiber)Delete a finished fiber and free its stack. Safe with NULL.
xFiberSwitchvoid xFiberSwitch(xFiber target)Suspend current fiber, resume target. Implicitly calls xFiberMain() if needed.
xFiberYieldvoid 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.
xFiberCurrentxFiber 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)

AspectDetail
Stack allocation`mmap(MAP_PRIVATE
Guard pagemprotect(PROT_NONE) on the bottom page
First entrymakecontext + setcontext
Yield / resumeswapcontext (atomically saves current ucontext and restores target; POSIX-blessed for cross-stack switching)
macOS arm64 notemakecontext variadic args cannot pass 64-bit pointers — trampoline reads proc/proc_arg from the fiber descriptor via TLS

Windows

AspectDetail
Stack allocationCreateFiberEx with FIBER_FLAG_FLOAT_SWITCH (preserves FPU/SSE/AVX)
Guard pageOS-managed via VirtualAlloc
First entrySwitchToFiber enters the trampoline automatically
Yield / resumeSwitchToFiber (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 thread
  • xFiberCreate() / xFiberDestroy(): Single-thread — operates on the calling thread's fiber set
  • xFiberCurrent(): Thread-safe — returns the calling thread's fiber
  • xFiberMain(): Thread-safe — idempotent per thread

Diagnostics

ConditionBehavior
xFiberSwitch(NULL)Silent no-op (returns immediately)
xFiberDestroy(current_fiber)Silent no-op (returns immediately)
Fiber proc returns without switchingabort() — fibers are a deterministic system and undefined transitions must fail hard
xFiberCreate allocation failureReturns NULL
xFiberYield from main threadSilent no-op
xFiberYield from root fiber (parent = NULL)Switches to main fiber

See Also

  • event.h — Event loop that drives fibers
  • promise.h (libxpp) — wait() integrates with fibers for non-blocking I/O
  • fiber.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

  1. 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.

  2. Simple Submit/Wait Model — Tasks are submitted with xTaskSubmit() and optionally awaited with xTaskWait(). This mirrors the future/promise pattern found in higher-level languages, but in pure C with minimal overhead.

  3. 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 must xTaskWait() first.

  4. 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.

  5. Global Shared Group — xTaskGroupGlobal() provides a lazily-initialized, process-wide task group with default settings (unlimited threads, no queue cap). It's automatically destroyed at atexit(), 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

TypeDescription
xTaskFuncvoid *(*)(void *arg) — Task function signature. Returns a result pointer.
xTaskOpaque handle to a submitted task
xTaskGroupOpaque handle to a task group (thread pool)
xTaskGroupConfConfiguration struct: nthreads (0 = auto), queue_cap (0 = unbounded)

Functions

FunctionSignatureDescriptionThread Safety
xTaskGroupCreatexTaskGroup xTaskGroupCreate(const xTaskGroupConf *conf)Create a task group. NULL conf = defaults.Not thread-safe
xTaskGroupDestroyvoid xTaskGroupDestroy(xTaskGroup g)Wait for pending tasks, then destroy.Not thread-safe
xTaskSubmitxTask xTaskSubmit(xTaskGroup g, xTaskFunc fn, void *arg)Submit a task. Returns NULL if queue is full.Thread-safe
xTaskWaitxErrno xTaskWait(xTask t, void **result)Block until task completes. Returns xErrno_Cancelled if the task was cancelled.Thread-safe
xTaskCancelxErrno xTaskCancel(xTask t)Cancel a queued task. Returns xErrno_Ok on success, xErrno_Busy if already running/done.Thread-safe
xTaskGroupWaitxErrno xTaskGroupWait(xTaskGroup g)Block until all pending tasks complete.Thread-safe
xTaskGroupThreadssize_t xTaskGroupThreads(xTaskGroup g)Return number of spawned worker threads.Thread-safe (atomic read)
xTaskGroupPendingsize_t xTaskGroupPending(xTaskGroup g)Return number of pending tasks.Thread-safe (atomic read)
xTaskGroupGlobalxTaskGroup 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

  1. CPU-Bound Parallel Processing — Distribute computation across multiple cores. Use xTaskGroupWait() to synchronize at barriers.

  2. Event Loop Offload — The event loop's xEventLoopSubmit() uses xTaskGroup internally to run work functions on worker threads, then delivers results back to the loop thread.

  3. 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 let xTaskGroupDestroy() clean up. Each xTaskSubmit() allocates a task struct (from the TLS freelist or malloc). Task memory is reclaimed when the done queue is drained (during xTaskGroupWait() or xTaskGroupDestroy()). Leaking task handles leaks resources.
  • Check xTaskCancel() return value before releasing the arg. xErrno_Ok means the task will not execute — safe to free. xErrno_Busy means it's already running or done — you must xTaskWait() first.
  • Set queue_cap for backpressure. Without a cap, unbounded submission can exhaust memory. A bounded queue lets you detect overload via NULL returns from xTaskSubmit().
  • Don't destroy the global group. xTaskGroupGlobal() is managed internally and destroyed at atexit(). Passing it to xTaskGroupDestroy() is undefined behavior.
  • Use xTaskGroupWait() for barriers, not busy-polling. It uses a dedicated condition variable and blocks efficiently.

Comparison with Other Libraries

Featurexbase task.hpthreadC11 threadsGCD (libdispatch)
AbstractionTask (submit/wait)Thread (create/join)Thread (create/join)Block (dispatch_async)
Thread ManagementAutomatic (lazy spawn)ManualManualAutomatic
QueueBuilt-in FIFO with capN/AN/ABuilt-in (serial/concurrent)
Result RetrievalxTaskWait(t, &result)pthread_join(t, &result)thrd_join(t, &result)Completion handler
Group WaitxTaskGroupWait()Manual barrierManual barrierdispatch_group_wait()
Backpressurequeue_cap → NULL on fullN/AN/AN/A (unbounded)
Global PoolxTaskGroupGlobal()N/AN/Adispatch_get_global_queue()
PlatformmacOS + LinuxPOSIXC11macOS + Linux (via libdispatch)
DependenciespthreadOSOSOS / 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():

  1. Acquire lock and increment idle count.
  2. Wait on qcond while the queue is empty and not shutting down.
  3. Dequeue one task, decrement idle.
  4. CAS state QUEUED → RUNNING — if the CAS fails (task was cancelled), skip execution.
  5. Execute task->fn(task->arg) (only if step 4 succeeded).
  6. Push to done queue via xMpscPush() (lock-free, wait-free for producers).
  7. Signal completion via xNoteSignal() (atomic store + kernel wake).
  8. Update counters — decrement pending, signal wcond if 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 — Wakes xTaskGroupWait() 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

  1. vtable-Driven Lifecycle — Each object type defines a static xVTable with optional function pointers for ctor, dtor, retain, release, copy, and move. This decouples lifecycle logic from the allocation mechanism, similar to C++ virtual destructors or Objective-C's class methods.

  2. Hidden Header Pattern — A Header struct 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.

  3. Atomic Reference Counting — xRetain() and xRelease() 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.

  4. Macro Convenience — XMALLOC(T) and XMALLOCEX(T, sz) macros generate the correct xAlloc() 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

MacroExpansionDescription
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

TypeDescription
xVTableStruct with function pointers: ctor, dtor, retain, release, copy, move

Functions

FunctionSignatureDescriptionThread Safety
xAllocvoid *xAlloc(const char *name, size_t size, size_t count, xVTable *vtab)Allocate object(s) with header and call ctor.Not thread-safe
xFreevoid xFree(void *ptr)Call dtor and free. Ignores NULL.Not thread-safe
xRetainvoid xRetain(void *ptr)Increment reference count atomically. Calls vtab->retain if set.Thread-safe
xReleasevoid xRelease(void *ptr)Decrement reference count atomically. Calls vtab->release then xFree when refs reach 0.Thread-safe
xCopyvoid xCopy(void *ptr, void *other)Call vtab->copy if set.Not thread-safe
xMovevoid 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

  1. Shared Ownership — Multiple components hold references to the same object (e.g., a connection shared between a reader and a writer). xRetain/xRelease ensures the object is freed only when the last reference is dropped.

  2. Plugin/Extension Objects — Define vtables for different object types that share a common interface. The vtable pattern enables polymorphic behavior in C.

  3. Debug-Friendly Allocation — The name field in the header enables allocation tracking and leak detection by type name.

Best Practices

  • Always pair xRetain with xRelease. Every retain must have a corresponding release, or you'll leak memory.
  • Use XMALLOC instead of raw xAlloc. 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 with xAlloc have a hidden header. Calling free() directly on the user pointer corrupts the heap.
  • Use XMALLOCEX for flexible array members. It adds extra bytes after the struct for variable-length data.

Comparison with Other Libraries

Featurexbase memory.hC++ RAIIObjective-C ARCGLib GObject
Mechanismvtable + atomic refcountDestructor + smart pointersCompiler-inserted retain/releaseGType + refcount
AutomationManual retain/releaseAutomatic (scope-based)Automatic (compiler)Manual ref/unref
Thread SafetyAtomic refcountshared_ptr is atomicAtomicAtomic
Polymorphismvtable function pointersVirtual functionsMethod dispatchSignal/slot + vtable
Overhead1 header per object (~32 bytes)0 (stack) or control block1 isa pointer + refcountLarge (GTypeInstance)
Flexible ArraysXMALLOCEX(T, sz)std::vectorNSMutableDataGArray
Debug InfoType name in headerRTTIClass nameGType name
LanguageC99C++Objective-CC (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

BenchmarkSize (bytes)Time (ns)CPU (ns)Iterations
BM_Memory_XAlloc1623.323.329,809,940
BM_Memory_XAlloc6421.121.132,551,024
BM_Memory_XAlloc25622.422.431,207,508
BM_Memory_XAlloc1,02420.120.134,024,352
BM_Memory_XAlloc4,09624.224.229,002,681
BM_Memory_Malloc1617.517.539,883,995
BM_Memory_Malloc6418.718.737,576,831
BM_Memory_Malloc25619.019.034,505,536
BM_Memory_Malloc1,02423.023.030,557,144
BM_Memory_Malloc4,09617.717.739,849,483
BM_Memory_RetainRelease—3.903.90183,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() and xRelease() are thread-safe — they use xAtomicAdd / xAtomicSub with sequential consistency ordering.
  • xAlloc(), xFree(), xCopy(), and xMove() 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

  1. 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.

  2. 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 (mmap on POSIX, VirtualAlloc on Windows) and fall back to malloc where neither is available.

  3. Uninitialised Memory — Slots are returned uninitialised; callers that previously relied on calloc's zeroing must call memset explicitly. This removes a per-alloc cost that is often wasted when the caller overwrites the fields immediately.

  4. 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.

  5. Spinlock-Guarded Multi-Thread Path — xSlabMt protects 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 stale next snapshot, 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.

  6. 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

MacroValueDescription
XSLAB_DEFAULT_ALIGN16Default slot alignment when obj_align == 0
XSLAB_DEFAULT_CHUNK_BYTES64 * 1024Default chunk size when chunk_bytes == 0

Types

TypeDescription
xSlabOpaque handle to a single-threaded pool
xSlabMtOpaque handle to a multi-threaded pool

Functions — xSlab (single-threaded)

FunctionSignatureDescription
xSlabCreatexSlab *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.
xSlabDestroyvoid xSlabDestroy(xSlab *s)Release all chunks. All outstanding slots become invalid. NULL is a no-op.
xSlabAllocvoid *xSlabAlloc(xSlab *s)Return one uninitialised slot of obj_size bytes at obj_align. NULL on OOM.
xSlabFreevoid xSlabFree(xSlab *s, void *p)Return a slot to the pool. NULL is a no-op. The slot must not be touched afterward.
xSlabResetvoid xSlabReset(xSlab *s)Bulk-reclaim every slot without freeing chunks. Caller must guarantee no slot is live.
xSlabInUsesize_t xSlabInUse(const xSlab *s)Number of slots currently handed out.
xSlabSlotSizesize_t xSlabSlotSize(const xSlab *s)Configured slot size (after alignment rounding).

Functions — xSlabMt (multi-threaded)

FunctionSignatureDescription
xSlabMtCreatexSlabMt *xSlabMtCreate(size_t obj_size, size_t obj_align, size_t chunk_bytes)Create a thread-safe pool. Same parameter semantics as xSlabCreate.
xSlabMtDestroyvoid xSlabMtDestroy(xSlabMt *s)Release all chunks. Caller must externally quiesce all users first.
xSlabMtAllocvoid *xSlabMtAlloc(xSlabMt *s)Thread-safe alloc. Lock-free fast path (CAS on freelist head).
xSlabMtFreevoid xSlabMtFree(xSlabMt *s, void *p)Thread-safe free. Lock-free fast path.
xSlabMtSlotSizesize_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

  1. 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.

  2. 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.

  3. Phase-Scoped Arenas via xSlabReset — When an entire subsystem is torn down, xSlabReset returns every slot at once without any per-slot bookkeeping. Combined with non-destructive teardown, it enables arena-style lifetimes in C.

  4. Cross-Thread Object Recycling — xSlabMt is 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 for xSlabMt only 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 on calloc.
  • 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 use xSlabFree / 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. xSlabReset does 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

FeaturexSlab / xSlabMtmalloc / freeThread-local freelistC++ std::pmr::pool_resource
Slot sizeFixed per poolArbitraryFixed per freelistFixed per pool
Alloc fast pathLoad + store (ST) / spinlock + load-store (MT)Size-class lookup + lockLoad + store, but only same threadSize-class lookup
Cross-thread freexSlabMt supports itYes (slow path)No (must return to origin)Depends on upstream
Per-slot headerNoneTypically 8–16 bytesNoneImplementation-defined
OS syscall rateOne mmap per chunk (64 KiB)Many mmap/sbrk depending on implNone (built on malloc)Depends on upstream
Bulk reclaimxSlabReset (O(chunks))NoNorelease()
Returns memory to OSOnly on DestroyDepends on implNoOn 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

BenchmarkTime (ns)Notes
BM_Slab_AllocFree2.58xSlabAlloc + xSlabFree, 32-byte slots
BM_Malloc_AllocFree18.9malloc + free, 32 bytes
BM_Calloc_AllocFree16.9calloc + 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)

BenchmarkBatchTime (ns)Slab vs malloc
BM_Slab_Batch1637.9
BM_Malloc_Batch16287slab 7.6× faster
BM_Slab_Batch256590
BM_Malloc_Batch2564,409slab 7.5× faster
BM_Slab_Batch4,09615,236
BM_Malloc_Batch4,09673,612slab 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

ThreadsxSlabMt (ns)malloc (ns)Winner
19.7918.8slab 1.9× faster
2~8091.3roughly tied
4540476malloc 1.1× faster
8~1,10046.4macOS malloc much faster

The crossover above four threads is real and worth understanding:

  • xSlabMt serialises 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 malloc up 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 eliminating calloc in the submission path rather than by the raw allocator being the fastest at high thread counts.
  • Zero-init is not free. BM_Calloc_AllocFree is ~10% faster than malloc on macOS because libmalloc short-circuits zeroing for freshly-mmaped pages. For pre-used memory callers should still memset.
  • Bulk xSlabReset is 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:

ModuleVariantSlotRationale
map.c (hash + tree backends)xSlabhash entry / tree nodemap operations are single-threaded; nodes are uniform-size.
timer.cxSlabMtxTimerTask_timer submission is cross-thread; push-mode hands the entry to the task pool.
task.cxSlabMtxTask_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

FunctionxSlabxSlabMt
Create / DestroyNot thread-safeNot thread-safe (caller must quiesce)
Alloc / FreeNot thread-safeThread-safe (spinlock-guarded)
ResetNot thread-safeN/A — xSlabMt has no bulk reclaim
InUse / SlotSizeNot thread-safe readSlotSize 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

  1. 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.

  2. 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.

  3. No Destructor Tracking — The arena does not know what objects were allocated from it. Callers are responsible for calling destructors / cleanup functions before xArenaDestroy() or xArenaReset(). This keeps the arena minimal and avoids the overhead of a destructor list.

  4. 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.

  5. Uninitialised Memory — xArenaAlloc() returns uninitialised memory (like malloc). Callers that need zeroed memory must call memset explicitly. This avoids wasted writes when the caller intends to overwrite every byte immediately.

  6. Single Buffer, No Chunk Chaining — Unlike xSlab (which grows by acquiring additional OS-backed chunks), xArena is backed by a single contiguous malloc-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

MacroValueDescription
XARENA_DEFAULT_ALIGN16Default alignment when align == 0 is passed to xArenaAllocAligned

Types

TypeDescription
xArenaOpaque handle to a fixed-capacity bump allocator

Functions

FunctionSignatureDescription
xArenaCreatexArena *xArenaCreate(size_t capacity)Create an arena with capacity bytes pre-allocated. Returns NULL on OOM.
xArenaDestroyvoid xArenaDestroy(xArena *a)Release the backing buffer and the arena handle. NULL is a no-op. All pointers become invalid.
xArenaAllocvoid *xArenaAlloc(xArena *a, size_t size)Bump-allocate size bytes with default (16-byte) alignment. Returns NULL if full.
xArenaAllocAlignedvoid *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.
xArenaCapacitysize_t xArenaCapacity(const xArena *a)Total capacity in bytes.
xArenaUsedsize_t xArenaUsed(const xArena *a)Bytes consumed so far (includes alignment padding).
xArenaRemainingsize_t xArenaRemaining(const xArena *a)Bytes still available (before accounting for future alignment padding).
xArenaOwnsint xArenaOwns(const xArena *a, const void *p)Returns non-zero if p points within the arena's backing buffer. O(1).
xArenaResetvoid 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

  1. 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 recursive free() loops.

  2. 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.

  3. 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.

  4. 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.

  5. 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 * 2 is 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 to malloc, 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 single malloc buffer — they cannot be individually freed. Attempting free() 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:

Featurexpp::Arena<N> (C++)xArena (C)
StorageInline for N ≤ 256, heap for N > 256 (compile-time)Always heap-allocated (runtime)
Allocationa.allocate(size, align)xArenaAlloc(a, size) / xArenaAllocAligned(a, size, align)
Ownershipa.owns(p) — O(1)xArenaOwns(a, p) — O(1)
Reseta.reset()xArenaReset(a)
Capacity querya.total_capacity() / a.remaining() / a.used()xArenaCapacity(a) / xArenaRemaining(a) / xArenaUsed(a)
Type-safe constructiona.make<T>(args...) — placement newN/A — C has no constructors
Move semanticsSupported (move ctor / move assignment)N/A — opaque handle, passed by pointer
LifetimeRAII (destructor frees buffer)Manual (xArenaDestroy)
Template / MacroCompile-time N parameterRuntime 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

  1. 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_Ok everywhere.

  2. 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.

  3. 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

TypeDescription
xErrnoint-based enum of error codes

Enum Values

ValueDescription
xErrno_OkSuccess
xErrno_UnknownUnspecified error (legacy / catch-all)
xErrno_InvalidArgNULL or invalid argument
xErrno_NoMemoryMemory allocation failed
xErrno_InvalidStateObject is in the wrong state for this call
xErrno_SysErrorUnderlying syscall / OS error
xErrno_NotFoundRequested item does not exist
xErrno_AlreadyExistsItem already registered / bound
xErrno_CancelledOperation was cancelled

Functions

FunctionSignatureDescriptionThread Safety
xstrerrorconst 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

  1. Uniform Error Propagation — Functions return xErrno and callers check against xErrno_Ok. This eliminates the need for module-specific error types.

  2. Logging and Diagnostics — xstrerror() provides instant human-readable messages for log output without maintaining separate message tables.

  3. Error Classification — Callers can switch on specific error codes to implement different recovery strategies (e.g., retry on xErrno_SysError, abort on xErrno_NoMemory).

Best Practices

  • Always check return values. Functions that return xErrno should 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

Featurexbase error.hPOSIX errnoWindows HRESULTGLib GError
Typeint enumint (thread-local)LONGStruct (domain + code + message)
ScopeLibrary-wideSystem-wideSystem-widePer-domain
String Conversionxstrerror()strerror()FormatMessage()g_error->message
Thread SafetyReturn value (inherently safe)Thread-local globalReturn valueHeap-allocated
ExtensibilityAdd to enumPlatform-definedFacility codesCustom domains
OverheadZero (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:

CodeValueMeaning
xErrno_Ok0Success
xErrno_Unknown1Unspecified error (legacy / catch-all)
xErrno_InvalidArg2NULL or invalid argument
xErrno_NoMemory3Memory allocation failed
xErrno_InvalidState4Object is in the wrong state for this call
xErrno_SysError5Underlying syscall / OS error
xErrno_NotFound6Requested item does not exist
xErrno_AlreadyExists7Item already registered / bound
xErrno_Cancelled8Operation 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

  1. Generic via Function Pointers — The heap stores void * elements and uses a xHeapCmpFunc for ordering. This makes it reusable for any element type without code generation or macros.

  2. Index Tracking — A xHeapSetIdxFunc callback notifies elements of their current position in the heap array. This enables O(1) lookup for xHeapRemove() and xHeapUpdate(), which would otherwise require O(n) search.

  3. 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.

  4. 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

TypeDescription
xHeapCmpFuncint (*)(const void *a, const void *b) — Returns negative if a < b, 0 if equal, positive if a > b
xHeapSetIdxFuncvoid (*)(void *elem, size_t idx) — Called when an element's index changes
xHeapOpaque handle to a min-heap

Functions

FunctionSignatureDescriptionThread Safety
xHeapCreatexHeap xHeapCreate(xHeapCmpFunc cmp, xHeapSetIdxFunc setidx, size_t cap)Create a heap. cap = 0 uses default (16).Not thread-safe
xHeapDestroyvoid xHeapDestroy(xHeap h)Free the heap. Does NOT free elements.Not thread-safe
xHeapPushxErrno xHeapPush(xHeap h, void *elem)Insert an element. O(log n).Not thread-safe
xHeapPeekvoid *xHeapPeek(xHeap h)Return the minimum element without removing. O(1).Not thread-safe
xHeapPopvoid *xHeapPop(xHeap h)Remove and return the minimum element. O(log n).Not thread-safe
xHeapRemovevoid *xHeapRemove(xHeap h, size_t idx)Remove element at index. O(log n).Not thread-safe
xHeapUpdatexErrno xHeapUpdate(xHeap h, size_t idx)Re-heapify after priority change. O(log n).Not thread-safe
xHeapSizesize_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

  1. Timer Subsystem — timer.h uses 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.

  2. Event Loop Timers — The event loop's builtin timer heap (event.h) uses the same pattern to integrate timer dispatch with I/O polling.

  3. 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() and xHeapUpdate() cannot locate elements efficiently.
  • Store the index in your element struct. The setidx callback 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() or xHeapPop().
  • Use xHeapUpdate() after changing an element's priority. The heap doesn't detect priority changes automatically.

Comparison with Other Libraries

Featurexbase heap.hC++ std::priority_queueLinux kernel prio_heapGo container/heap
Element Typevoid * (generic)TemplateFixed structinterface{}
Index TrackingBuilt-in (setidx callback)Not availableNot availableManual (Fix method)
Remove by IndexO(log n)Not supportedNot supportedO(log n) via Remove
Update PriorityO(log n) via xHeapUpdateNot supportedNot supportedO(log n) via Fix
OwnershipNo (caller owns elements)Yes (copies/moves)NoNo
Thread SafetyNot thread-safeNot thread-safeNot thread-safeNot 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

BenchmarkNTime (ns)CPU (ns)Throughput
BM_Heap_Push89839878.1 M items/s
BM_Heap_Push641,6941,69937.7 M items/s
BM_Heap_Push5128,7228,72558.7 M items/s
BM_Heap_Push4,09656,85456,85372.0 M items/s
BM_Heap_Pop81,0201,0247.8 M items/s
BM_Heap_Pop642,8072,80922.8 M items/s
BM_Heap_Pop51226,33426,33719.4 M items/s
BM_Heap_Pop4,096297,382297,32513.8 M items/s
BM_Heap_Remove81,0151,0207.8 M items/s
BM_Heap_Remove641,8081,81135.3 M items/s
BM_Heap_Remove5128,9148,90357.5 M items/s
BM_Heap_Remove4,09668,01768,01660.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 setidx callback 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

OperationFunctionTime ComplexityDescription
InsertxHeapPushO(log n)Append to end, sift up
Peek minxHeapPeekO(1)Return data[0]
Extract minxHeapPopO(log n)Swap with last, sift down
Remove by indexxHeapRemoveO(log n)Swap with last, sift up + down
Update priorityxHeapUpdateO(log n)Sift up + down at index
SizexHeapSizeO(1)Return size field
Growensure_capAmortized 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

  1. vtable-Driven Polymorphism — All backends share a common xMapVTable dispatch table. The public API (xMapSet, xMapGet, xMapDel, etc.) forwards calls through function pointers, so callers can switch backends by changing a single xMapType argument without touching any other code.

  2. Opaque Keys and Values — The map stores const void * keys and void * values. Hash and equality functions are user-supplied, making the map reusable for any key type (strings, integers, structs) without code generation or macros.

  3. Single-Allocation Construction — The hash and flat backends allocate the struct header and the initial bucket/slot array in one contiguous calloc call. This reduces allocation overhead and improves cache locality for small maps.

  4. 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.

  5. 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 cap parameter 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

OperationHash (avg)Hash (worst)Flat (avg)Flat (worst)Tree
xMapSetO(1)O(n)O(1)O(n)O(log n)
xMapGetO(1)O(n)O(1)O(n)O(log n)
xMapDelO(1)O(n)O(1)O(n)O(log n)
xMapLenO(1)O(1)O(1)O(1)O(1)
xMapIterateO(n + cap)O(n + cap)O(cap)O(cap)O(n)
xMapCreateO(cap)O(cap)O(cap)O(cap)O(1)
xMapDestroyO(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

TypeDescription
xMapTypeEnum: xMapType_Hash (separate chaining), xMapType_Flat (open addressing), xMapType_Tree (red-black tree)
xMapOpaque handle to a map
xMapHashFuncuint64_t (*)(const void *key) — Returns a 64-bit hash for the given key
xMapEqFuncbool (*)(const void *a, const void *b) — Returns true if two keys are equal
xMapIterFuncbool (*)(const void *key, void *val, void *arg) — Iterator callback; return false to stop early

Functions

FunctionSignatureDescriptionThread Safety
xMapCreatexMap 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
xMapDestroyvoid xMapDestroy(xMap m)Free the map. Does NOT free user keys/values. NULL is a safe no-op.Not thread-safe
xMapSetxErrno xMapSet(xMap m, const void *key, void *val)Insert or update a key-value pair. Returns xErrno_Ok or xErrno_NoMemory.Not thread-safe
xMapGetvoid *xMapGet(xMap m, const void *key)Look up a value by key. Returns NULL if not found.Not thread-safe
xMapDelvoid *xMapDel(xMap m, const void *key)Remove a key-value pair. Returns the removed value, or NULL.Not thread-safe
xMapLensize_t xMapLen(xMap m)Return the number of entries. O(1).Not thread-safe
xMapIteratevoid 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

FunctionDescription
xMapStrHashFNV-1a 64-bit hash for NUL-terminated C strings
xMapStrEqstrcmp-based equality for C strings
xMapIntHashSplitmix64 finalizer for integer keys cast to (void *)
xMapIntEqPointer-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

CriteriaHashFlatTree
Average lookupO(1) ✅O(1) ✅O(log n)
Worst-case lookupO(n)O(n)O(log n) ✅
Cache localityPoor (pointer chasing)Excellent ✅Poor (pointer chasing)
Iteration speedVisits empty bucketsVisits empty slotsVisits only entries ✅
Ordered iterationNoNoYes (by hash) ✅
Resize pausesYes (rehash)Yes (rehash)No ✅
Memory overheadEntry nodes + bucket arraySlot array (inline) ✅Node + parent/child pointers
DeletionFree entry nodeTombstone markerRB fixup or overflow promotion
Best forGeneral purposeSmall keys, hot loopsOrdered 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

  1. Session Management — Store active sessions keyed by session ID (string). The hash backend provides O(1) average lookup for connection dispatch.

  2. Configuration Registry — Map string keys to configuration values. The tree backend provides ordered iteration for serialization.

  3. Object Caches — Cache computed results keyed by integer IDs. The flat backend's cache-friendly layout minimizes latency for hot-path lookups.

  4. Symbol Tables — Compilers and interpreters can use the map to store variable bindings, with string keys and pointer values.

Best Practices

  • Always provide both hash and eq. The map requires both functions; passing NULL for either causes xMapCreate to return NULL.
  • Use the built-in helpers when possible. xMapStrHash/xMapStrEq and xMapIntHash/xMapIntEq are 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 cap hint to xMapCreate to avoid early resizes. For hash and flat backends, capacity should be a power of 2.
  • Prefer xMapType_Hash as the default. It handles the widest range of workloads well. Only switch backends based on profiling data.

Comparison with Other Libraries

Featurexbase map.hC++ std::unordered_mapGo mapGLib GHashTableuthash
LanguageC99C++GoCC (macros)
Key Typevoid * (generic)TemplatecomparablegpointerStruct field
Multiple BackendsHash / Flat / Tree ✅Hash onlyHash onlyHash onlyHash only
Ordered IterationTree backend ✅No (std::map for ordered)NoNoNo
OwnershipNo (caller owns)Yes (copies)Yes (copies)NoNo
Thread SafetyNot thread-safeNot thread-safeNot thread-safeNot thread-safeNot thread-safe
Resize Strategy2× with rehashBucket-based rehashIncrementalBucket-based rehashBucket-based rehash
IntrusiveNoNoNoNoYes (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.cpp

The 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)

BenchmarkNTime (ns)CPU (ns)Throughput
BM_Map_Set_Hash644,8794,87913.1 M items/s
BM_Map_Set_Hash5129,0279,02756.7 M items/s
BM_Map_Set_Hash4,09656,78156,77972.1 M items/s
BM_Map_Set_Hash32,768713,860713,81045.9 M items/s
BM_Map_Set_Flat641,0611,06260.2 M items/s
BM_Map_Set_Flat5125,5075,50893.0 M items/s
BM_Map_Set_Flat4,09648,03348,03685.3 M items/s
BM_Map_Set_Flat32,768689,267689,27547.5 M items/s
BM_Map_Set_Tree645,2655,26812.1 M items/s
BM_Map_Set_Tree51211,23211,23345.6 M items/s
BM_Map_Set_Tree4,096146,120146,12028.0 M items/s
BM_Map_Set_Tree32,7683,154,7283,154,59810.4 M items/s

Get (Lookup)

BenchmarkNTime (ns)CPU (ns)Throughput
BM_Map_Get_Hash64214214298.7 M items/s
BM_Map_Get_Hash5121,9671,967260.3 M items/s
BM_Map_Get_Hash4,09620,19220,187202.9 M items/s
BM_Map_Get_Hash32,768207,804207,791157.7 M items/s
BM_Map_Get_Flat64243243263.8 M items/s
BM_Map_Get_Flat5122,2762,276224.9 M items/s
BM_Map_Get_Flat4,09622,25822,256184.0 M items/s
BM_Map_Get_Flat32,768256,893256,885127.6 M items/s
BM_Map_Get_Tree64438438146.1 M items/s
BM_Map_Get_Tree5124,8294,829106.0 M items/s
BM_Map_Get_Tree4,09660,68760,68767.5 M items/s
BM_Map_Get_Tree32,7682,600,9102,600,79212.6 M items/s

Del (Delete)

BenchmarkNTime (ns)CPU (ns)Throughput
BM_Map_Del_Hash641,2471,25051.2 M items/s
BM_Map_Del_Hash5123,3663,371151.9 M items/s
BM_Map_Del_Hash4,09623,81823,814172.0 M items/s
BM_Map_Del_Hash32,768209,060209,018156.8 M items/s
BM_Map_Del_Flat641,1531,15555.4 M items/s
BM_Map_Del_Flat5123,0263,030169.0 M items/s
BM_Map_Del_Flat4,09621,23621,243192.8 M items/s
BM_Map_Del_Flat32,768270,593268,020122.3 M items/s
BM_Map_Del_Tree641,7881,79135.7 M items/s
BM_Map_Del_Tree5128,5248,52760.0 M items/s
BM_Map_Del_Tree4,096146,494145,90728.1 M items/s
BM_Map_Del_Tree32,7682,672,1922,672,15512.3 M items/s

Iterate

BenchmarkNTime (ns)CPU (ns)Throughput
BM_Map_Iterate_Hash64128128500.2 M items/s
BM_Map_Iterate_Hash5121,0301,030497.3 M items/s
BM_Map_Iterate_Hash4,0968,4368,436485.5 M items/s
BM_Map_Iterate_Hash32,768169,785169,780193.0 M items/s
BM_Map_Iterate_Flat64120120534.7 M items/s
BM_Map_Iterate_Flat512973973526.0 M items/s
BM_Map_Iterate_Flat4,0967,7757,774526.9 M items/s
BM_Map_Iterate_Flat32,768113,315113,308289.2 M items/s
BM_Map_Iterate_Tree64154154416.7 M items/s
BM_Map_Iterate_Tree5121,2351,235414.4 M items/s
BM_Map_Iterate_Tree4,09610,81310,812378.8 M items/s
BM_Map_Iterate_Tree32,768178,903178,901183.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

  1. 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 or void * casts.

  2. Circular Sentinel — The list head is itself an xList node whose next and prev point back to itself when empty. This eliminates special-case branching for head/tail operations — every insertion and deletion follows the same pointer manipulation.

  3. Inline Implementation — All functions are declared XCAPI_INLINE, so the entire list implementation lives in the header with no separate .c file. This gives the compiler full visibility for inlining and constant propagation, yielding zero-overhead list operations.

  4. Poison Pointers — After removal, a node's next and prev are overwritten with sentinel values (0xDEAD / 0xBEEF). Accessing a removed node's links will trigger an obvious crash, catching use-after-remove bugs early.

  5. Safe Iteration Macros — xListForEachSafe and xListForEachEntrySafe stash 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

TypeDescription
xListDoubly-linked list node. Embed in your struct as a member.

Functions

FunctionSignatureDescriptionThread Safety
xListInitvoid xListInit(xList *head)Initialize a list head as an empty circular listNot thread-safe
xListAddvoid xListAdd(xList *prev, xList *node)Insert node after prevNot thread-safe
xListAddHeadvoid xListAddHead(xList *head, xList *node)Insert node at the head of the list (equivalent to xListAdd(head, node))Not thread-safe
xListAddTailvoid xListAddTail(xList *head, xList *node)Insert node at the tail of the list (equivalent to xListAdd(head->prev, node))Not thread-safe
xListAddBeforevoid xListAddBefore(xList *next, xList *node)Insert node before nextNot thread-safe
xListDelvoid xListDel(xList *node)Remove node from its list and poison its pointersNot thread-safe
xListEmptybool xListEmpty(xList *head)Return true if the list is emptyNot thread-safe

Macros

MacroParametersDescription
xListForEach(pos, head)pos: iterator (xList *), head: list headIterate over raw list nodes
xListForEachSafe(pos, tmp, head)pos: iterator, tmp: temp, head: list headIterate with safe deletion support
xListForEachEntry(pos, head, member)pos: struct pointer iterator, head: list head, member: name of xList fieldIterate 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 fieldIterate 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

  1. Timer Entry Queue — timer.h links timer entries via an embedded xList node for O(1) insertion and removal of timer callbacks.

  2. Connection List — Async socket implementations can chain active connections in a list, enabling O(1) connect/disconnect without external allocation.

  3. Task Scheduling — A thread pool can maintain per-worker task lists using xListAddHead/xListAddTail/xListDel, with xListForEachEntrySafe for graceful shutdown that drains and cancels pending tasks.

  4. Event Callback Chains — Multiple listeners on the same event can be linked in a list, each embedding an xList node in their handler struct.

Best Practices

  • Always use the safe variants when deleting during iteration. xListForEach / xListForEachEntry will crash if the current node is deleted mid-loop. Use xListForEachSafe / xListForEachEntrySafe instead.
  • Initialize before use. An uninitialized xList has indeterminate pointers. Always call xListInit() 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, xListAddTail inserts before the head sentinel, appending to the tail in O(1). Similarly, use xListAddHead(head, ...) for head insertion.
  • Check poison after removal for debugging. After xListDel(), node->next == 0xDEAD signals a use-after-remove bug if you accidentally access the node's links.

Comparison with Other Libraries

Featurexbase list.hLinux kernel list.hC++ std::listGLib GListutlist
StyleIntrusiveIntrusiveNon-intrusiveNon-intrusiveIntrusive (macros)
AllocationNone (embedded)None (embedded)Per-node heapPer-node heapNone (embedded)
CircularYesYesNo (sentinel node)No (NULL-terminated)Optional
Head/Tail HelpersYes (xListAddHead, xListAddTail)Yes (list_add, list_add_tail)Yes (push_front, push_back)Yes (g_list_append, g_list_prepend)No
Poison PointersYesYesNoNoNo
Safe IterationYes (macro)Yes (macro)Yes (iterator)Yes (manual)No
Thread SafetyNot thread-safeNot thread-safeNot thread-safeNot thread-safeNot thread-safe
Inline ImplementationYes (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

OperationFunction / MacroTime ComplexityDescription
InitializexListInitO(1)Set next = prev = head (circular empty)
Insert afterxListAddO(1)Link node after a given node
Insert at headxListAddHeadO(1)Insert node right after the list head
Insert at tailxListAddTailO(1)Insert node right before the list head (tail)
Insert beforexListAddBeforeO(1)Link node before a given node
RemovexListDelO(1)Unlink node + poison pointers
Is emptyxListEmptyO(1)Check head->next == head
IteratexListForEachO(n)Forward traversal (raw xList *)
Iterate safexListForEachSafeO(n)Forward traversal with deletion support
Iterate entriesxListForEachEntryO(n)Forward traversal (struct pointers via xContainerOf)
Iterate entries safexListForEachEntrySafeO(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

  1. 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.

  2. Callback-Driven Lifecycle — Optional retain, release, and equal callbacks 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 plain realloc-based buffer.

  3. Opaque Handle — xArray is an opaque pointer (XDEF_HANDLE). The internal struct (xArray_) is defined only in array.c, so callers cannot depend on layout details. Growth may relocate the entire object (header + data), which is why xArrayPush and xArrayResize take xArray *arrp and update the handle in place.

  4. Doubling Growth — When capacity is exhausted, the array doubles its capacity (starting from a default of 8). This yields amortised O(1) Push and avoids the O(n) per-insert reallocation of naive strategies.

  5. Zero-Initialised Slots — Every new element is memset to zero before the retain callback fires. This means callers can safely check slot->ptr != NULL inside 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

TypeDescription
xArrayOpaque handle to a dynamic array (XDEF_HANDLE).
xArrayCallbacksStruct with optional retain, release, and equal callbacks.
xArrayRetainFuncCallback type: void (*)(void *elem). Called when an element is added.
xArrayReleaseFuncCallback type: void (*)(void *elem). Called when an element is removed.
xArrayEqualFuncCallback type: int (*)(const void *elem, const void *key). Called by xArrayFind.

Lifecycle Functions

FunctionSignatureDescriptionThread Safety
xArrayCreatexArray 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
xArrayDestroyvoid xArrayDestroy(xArray arr)Release all elements and free the array. NULL is a no-op.Not thread-safe
xArrayResetvoid xArrayReset(xArray arr)Release all elements but keep the allocated storage for reuse.Not thread-safe

Mutator Functions

FunctionSignatureDescriptionThread Safety
xArrayPushvoid *xArrayPush(xArray *arrp)Append a zero-initialised element. May realloc (updates *arrp). Returns pointer to new slot, or NULL on failure.Not thread-safe
xArrayPopxErrno xArrayPop(xArray arr)Remove the last element (calls release). Returns xErrno_InvalidState if empty.Not thread-safe
xArrayResizexErrno xArrayResize(xArray *arrp, size_t new_len)Set exact length. Growing zero-inits + retain new slots; shrinking releases removed slots.Not thread-safe
xArrayRemoveRangexErrno 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

FunctionSignatureDescriptionThread Safety
xArrayAtvoid *xArrayAt(xArray arr, size_t idx)Pointer to element at idx. Returns NULL if out of range.Not thread-safe
xArrayLensize_t xArrayLen(xArray arr)Number of stored elements.Not thread-safe
xArrayCapsize_t xArrayCap(xArray arr)Current capacity (elements before realloc needed).Not thread-safe
xArrayDatavoid *xArrayData(xArray arr)Raw pointer to element storage. Valid until next mutation. NULL if empty.Not thread-safe
xArrayFindsize_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

  1. Session History — The xagent module stores AI session conversation history in an xArray of struct xAgentSessionMsg_. The release callback frees each message's heap-owned strings (text, tool-use arguments, tool-result output), and xArrayRemoveRange handles history trimming.

  2. Query Turn Buffers — The xagent module's xAgentQuery_ uses separate xArray instances for inputs, produced output, and pending tool calls. The release callbacks clean up per-element resources when the query is destroyed or reset.

  3. Timer Entry Queue — A timer subsystem can store active timer entries in an xArray, using xArrayRemoveRange to cancel a batch of timers and the release callback to free timer-specific resources.

  4. 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 xArray with no callbacks for plain value storage.

Best Practices

  • Always pass xArray *arrp to xArrayPush and xArrayResize. These functions may reallocate the entire array object, invalidating the old handle. Never store the result of xArrayAt / xArrayData across 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, and xArrayDestroy safe without caller-side loops.
  • Don't call xArrayPop on an empty array. It returns xErrno_InvalidState. Check xArrayLen(arr) > 0 first if the array might be empty.
  • Avoid retaining pointers across mutations. xArrayAt and xArrayData return pointers into the internal buffer. Any Push, Resize, or RemoveRange may move memory. Copy the data out if you need it to survive.
  • Prefer xArrayReset over Destroy+Create. If you need to empty an array but expect to refill it soon, xArrayReset preserves the allocated capacity, avoiding a fresh allocation cycle.
  • Use xArrayRemoveRange for 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

Featurexbase array.hC++ std::vectorGLib GArrayapr_array_header_t (APR)
StyleOpaque handleTemplate classOpaque structStruct + macros
LanguageC99C++CC
Growth StrategyDoubleImplementation-defined (usually double)DoubleManual (apr_array_push)
Element SizeCaller-specifiedTemplate parameterCaller-specifiedCaller-specified
Lifecycle CallbacksYes (retain/release/equal)No (RAII per element)No (clear func)No
Range RemovalxArrayRemoveRangeerase(first, last)No built-inNo built-in
FindxArrayFind (callback)std::find (algorithm)No built-inNo built-in
Opaque HandleYesNo (header-only template)YesNo
Thread SafetyNot thread-safeNot thread-safeNot thread-safeNot 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:

  1. Compute the next power-of-two capacity that satisfies the demand (starting from ARRAY_DEFAULT_CAP = 8).
  2. realloc the entire block (header + data).
  3. Update the caller's xArray handle via the arrp pointer.

This means any pointer obtained from xArrayAt / xArrayData is invalidated by a subsequent xArrayPush or xArrayResize that triggers growth.

Callback Semantics

CallbackWhen CalledElement State
retainAfter xArrayPush or xArrayResize (growing)Zero-initialised, before caller fills fields
releasexArrayPop, xArrayReset, xArrayDestroy, xArrayResize (shrinking), xArrayRemoveRangeStill in its original memory location
equalxArrayFindRead-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

OperationFunctionTime ComplexityDescription
CreatexArrayCreateO(1)Allocate header + initial data buffer
DestroyxArrayDestroyO(n)Release each element + free block
ResetxArrayResetO(n)Release each element, keep capacity
PushxArrayPushAmortised O(1)Append + grow if needed
PopxArrayPopO(1)Release last + decrement length
ResizexArrayResizeO(n)Grow or shrink to exact length
Remove rangexArrayRemoveRangeO(n)Release range + memmove survivors
Element accessxArrayAtO(1)Pointer arithmetic into data
LengthxArrayLenO(1)Read len field
CapacityxArrayCapO(1)Read cap field
Raw dataxArrayDataO(1)Return pointer to first element
FindxArrayFindO(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

  1. Binary-Compatible with C Strings — XString is a typedef char *. Every XString can be passed directly to any C string API without conversion. It is always NUL-terminated.

  2. Hidden Header — The metadata (length, capacity) lives in a header placed before the user pointer. This means XString is indistinguishable from a regular char* at the call site, yet length queries are O(1).

  3. 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.

  4. Binary-Safe — Embedded NUL bytes are supported. XStringCreateLen and XStringAppendLen treat the input as raw bytes. Length is tracked explicitly, not via strlen.

  5. Dual-Strategy Search — XStringFind uses naive memcmp for short patterns (below a threshold) and platform memmem for 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 / ConstantDescription
xStringtypedef 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

FunctionSignatureDescriptionThread Safety
xStringCreatexString xStringCreate(const char *init)Create from C string. init may be NULL (→ empty).Not thread-safe
xStringCreateLenxString xStringCreateLen(const void *init, size_t len)Create from raw memory (binary-safe). init may be NULL if len == 0.Not thread-safe
xStringDestroyvoid xStringDestroy(xString s)Free the string. NULL is a no-op.Not thread-safe
xStringDupxString xStringDup(const xString s)Deep copy. NULL → NULL.Not thread-safe

Append Functions

FunctionSignatureDescriptionThread Safety
xStringAppendxString xStringAppend(xString s, const char *append)Append C string. May realloc; use return value.Not thread-safe
xStringAppendLenxString xStringAppendLen(xString s, const void *append, size_t len)Append raw bytes (binary-safe).Not thread-safe
xStringAppendFormatxString xStringAppendFormat(xString s, const char *fmt, ...)Append printf-style formatted string.Not thread-safe

Truncate / Clear

FunctionSignatureDescriptionThread Safety
xStringTruncatevoid xStringTruncate(xString s, size_t new_len)Shorten to new_len. No-op if new_len > len. Does not shrink allocation.Not thread-safe
xStringClearvoid xStringClear(xString s)Reset to empty string "". Does not shrink allocation.Not thread-safe

Accessor Functions

FunctionSignatureDescriptionThread Safety
xStringLensize_t xStringLen(const xString s)String length in O(1). NULL → 0.Not thread-safe
xStringCapsize_t xStringCap(const xString s)Allocated capacity. NULL → 0.Not thread-safe
xStringAvailsize_t xStringAvail(const xString s)Available space = cap − len. NULL → 0.Not thread-safe

Memory Control Functions

FunctionSignatureDescriptionThread Safety
xStringGrowxString xStringGrow(xString s, size_t add_len)Pre-allocate for add_len more bytes. Does not change length.Not thread-safe
xStringShrinkToFitxString xStringShrinkToFit(xString s)Realloc to fit content exactly. On failure, keeps original allocation.Not thread-safe

Search Functions

FunctionSignatureDescriptionThread Safety
xStringFindsize_t xStringFind(const xString haystack, const char *needle, size_t needle_len)Binary-safe search. Returns byte index or XSTRING_NONE.Not thread-safe
xStringFindStrsize_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

FunctionSignatureDescriptionThread Safety
xStringCmpint xStringCmp(const xString s1, const xString s2)Binary-safe comparison. Returns <0, 0, >0. NULL sorts before non-NULL.Not thread-safe
xStringEqint 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

  1. 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 strlen is unreliable.

  2. Log Message Assembly — xStringAppendFormat provides a convenient way to build structured log lines incrementally, with automatic growth and no fixed-size buffer overflow risk.

  3. Configuration String Handling — xString can hold user-provided configuration values, supporting both C-string APIs and explicit-length operations. xStringFindStr enables simple key-value parsing.

  4. 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/snprintf management.

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_NONE to check search results. if (xStringFindStr(s, "key") != XSTRING_NONE) is clearer and more idiomatic than comparing against (size_t)-1.
  • Prefer xStringCreateLen for binary data. xStringCreate uses strlen internally and will stop at the first NUL byte. xStringCreateLen copies exactly the bytes you specify.
  • Use xStringClear instead of Destroy+Create for reuse. xStringClear resets to an empty string while preserving the allocated capacity, avoiding a fresh allocation cycle.
  • Pre-allocate with xStringGrow for known sizes. If you know the approximate final size, xStringGrow avoids 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

Featurexbase string.hRedis SDSC++ std::stringbstring
Stylechar* typedefchar* typedefClassOpaque struct
LanguageC99CC++C
C String CompatibleYesYesNo (.c_str())No
Binary-SafeYesYesYesYes
O(1) LengthYesYesYesYes
Auto-Growing AppendYesYesYesYes
Formatted AppendxStringAppendFormatsdscatprintfstd::format_toNo built-in
SearchxStringFind (threshold)strstr onlyfind()bfind
Thread SafetyNot thread-safeNot thread-safeNot thread-safeNot 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) — reads hdr->len directly.
  • s can be passed to any const char* API.
  • The NUL terminator is always written after len bytes.

Growth Strategy

When an append exceeds current capacity:

  1. If current capacity < 1 MB → double the capacity.
  2. If current capacity ≥ 1 MB → add 1 MB.
  3. Minimum capacity is XSTRING_MIN_CAP = 64 bytes.

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 LengthAlgorithmRationale
< XSTRING_FIND_THRESHOLD (32)Naive memcmp scanAvoids memmem call overhead for short patterns where O(n·m) is negligible.
≥ XSTRING_FIND_THRESHOLDPlatform memmemLeverages 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

OperationFunctionTime ComplexityDescription
CreatexStringCreateO(n)Copy init string + allocate header
Create (binary)xStringCreateLenO(n)Copy n bytes + allocate header
DestroyxStringDestroyO(1)Free the single allocation
DuplicatexStringDupO(n)Copy all data into new allocation
AppendxStringAppendAmortised O(n)May realloc, then memcpy
Append (binary)xStringAppendLenAmortised O(n)May realloc, then memcpy
Append (format)xStringAppendFormatAmortised O(n)vsnprintf into available space; grow + retry if needed
TruncatexStringTruncateO(1)Write NUL, update len
ClearxStringClearO(1)Write NUL at index 0, set len = 0
LengthxStringLenO(1)Read header field
CapacityxStringCapO(1)Read header field
AvailablexStringAvailO(1)cap − len
GrowxStringGrowO(n)Pre-allocate, may realloc
Shrink to fitxStringShrinkToFitO(n)realloc to exact size
FindxStringFindO(n·m) or O(n+m)Threshold-based: naive or memmem
Find (C string)xStringFindStrO(n·m) or O(n+m)Delegates to xStringFind
ComparexStringCmpO(n)Binary-safe memcmp
EqualxStringEqO(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

  1. Intrusive Design — Nodes embed an xMpsc struct directly, avoiding heap allocation per enqueue. This is critical for hot paths like timer expiry and offload completion where allocation overhead would be unacceptable.

  2. 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.

  3. 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.

  4. Minimal Memory Ordering — The implementation uses xAtomicAcqRel for the exchange and xAtomicAcquire/xAtomicRelease for 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

TypeDescription
xMpscIntrusive queue node. Embed in your struct and use xContainerOf() to recover the enclosing struct.

Functions

FunctionSignatureDescriptionThread Safety
xMpscPushvoid xMpscPush(xMpsc **head, xMpsc **tail, xMpsc *node)Push a node. Wait-free for producers.Thread-safe (multi-producer)
xMpscPopxMpsc *xMpscPop(xMpsc **head, xMpsc **tail)Pop the oldest node. Returns NULL if empty.Single-consumer only
xMpscEmptybool 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

  1. Timer Poll Mode — timer.h uses the MPSC queue in poll mode to pass expired timer entries from the timer thread to the polling thread without locks.

  2. 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.

  3. xlog Async Logger — logger.h uses the MPSC queue to pass log messages from application threads to the logger's flush thread.

Best Practices

  • Embed xMpsc in your struct. Don't allocate xMpsc nodes separately. Use xContainerOf() 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

Featurexbase mpsc.hDmitry Vyukov MPSCconcurrentqueue (C++)Linux llist
DesignIntrusive, lock-freeIntrusive, lock-freeNon-intrusive, lock-freeIntrusive, lock-free
PushWait-free (1 atomic xchg)Wait-free (1 atomic xchg)Lock-free (CAS loop)Wait-free (1 atomic xchg)
PopLock-free (single consumer)Lock-free (single consumer)Lock-free (multi-consumer)Batch pop (splice)
Memory OrderingAcqRel / Acquire / ReleaseSeqCstRelaxed + fencesVaries
AllocationNone (intrusive)None (intrusive)Per-element (internal)None (intrusive)
Multi-ConsumerNoNoYesNo (batch only)
LanguageC99C/C++C++11C (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

BenchmarkTime (ns)CPU (ns)IterationsThroughput
BM_Mpsc_SingleProducer3,7123,712187,897275.9 M items/s
BM_Mpsc_MultiProducer/2609,43287,7978,075227.8 M items/s
BM_Mpsc_MultiProducer/41,327,965148,3564,768269.6 M items/s
BM_Mpsc_MultiProducer/84,466,805292,2601,000273.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:

  1. Empty queue — head is NULL, return NULL.
  2. Multiple nodes — Advance head to head->next, return old head.
  3. Single node — CAS tail to NULL. If CAS succeeds, also CAS head to NULL. If CAS fails (concurrent push in progress), spin until head->next becomes 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

OperationOrderingReason
xAtomicXchg(tail, node)AcqRelAcquire: see previous tail's next field. Release: make node visible to consumer.
xAtomicStore(head, node)ReleaseMake the new head visible to the consumer.
xAtomicLoad(head)AcquireSee the node written by the producer.
xAtomicLoad(&head->next)AcquireSee the next pointer written by the producer.
xAtomicCasStrong(tail, ...)ReleasePublish 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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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 the main() 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

TypeDescription
xRelayOpaque relay handle. Created by xRelayCreate(), destroyed by xRelayDestroy().
xRelayFuncvoid (*)(void *data, void *arg) — subscriber callback signature.

Functions

FunctionSignatureDescriptionThread Safety
xRelayCreatexRelay *xRelayCreate(void)Create a new relay with zero subscribers. Returns NULL on OOM.Thread-safe (single-threaded creation)
xRelayOnxErrno 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
xRelayOffvoid xRelayOff(xRelay *r, xRelayFunc fn, void *arg)Remove the first subscriber matching {fn, arg}. No-op if not found.Thread-safe
xRelayEmitvoid 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
xRelayDestroyvoid 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 (one calloc + 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 xEventLoopPost overhead.

  • 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).

  • xRelayOff before destroying a subscriber's loop. If a subscriber's event loop is being torn down, unsubscribe first. Otherwise, a concurrent emit may xEventLoopPost to 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 xRelayOff acquires a mutex, which adds latency to the callback path.

Thread Safety

OperationSafety
xRelayCreateCall once, single-threaded.
xRelayOnThread-safe. Can be called concurrently with xRelayEmit and other xRelayOn calls.
xRelayOffThread-safe. Can be called concurrently with xRelayEmit and other mutations.
xRelayEmitThread-safe. Can be called concurrently from multiple threads.
xRelayDestroyCall 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

  1. Count subscribers under the mutex.
  2. Allocate snapshot array — stack array for ≤16 subscribers, heap otherwise.
  3. Fill snapshot under the mutex — pointer copies only, no data movement.
  4. Release the mutex — the critical section is over (~100ns).
  5. Dispatch — for each subscriber in the snapshot:
    • Same loop or NULL loop → call sub->fn(data, sub->arg) synchronously.
    • Different loop → calloc a xRelayDispatch_, copy data with memcpy, xEventLoopPost.

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.

FeaturexRelayxMpscxNote
Pattern1:N fan-out pub/subMPMC queue (single consumer)1:1 one-shot signal
BufferingNone (synchronous or posted)Unbounded FIFONone (single flag)
DeliveryPer-subscriber loop-awareSingle consumer popsWaiter polls
Allocation per emit0 (same-loop), 1 per cross-loop sub0 (intrusive)0
Use caseCross-module eventsWork queues, timer dispatchCompletion 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

  1. Thin Macro Wrappers — Each macro maps directly to a compiler builtin with zero overhead. No abstraction layers, no runtime dispatch.

  2. 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 expensive SeqCst.

  3. GCC/Clang Builtins — The __atomic builtins are supported by GCC ≥ 4.7 and all versions of Clang. They generate optimal instructions for each target architecture (x86: lock prefix, ARM: ldrex/strex or 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

  1. Reference Counting — memory.h uses xAtomicAdd/xAtomicSub with SeqCst ordering for thread-safe reference count management.

  2. Lock-Free Data Structures — mpsc.h uses xAtomicXchg for wait-free push and xAtomicCasStrong for the single-element pop edge case.

  3. Event Loop Internals — The event loop uses xAtomicFetchAdd/xAtomicFetchSub with Relaxed ordering to track in-flight offload workers.

Best Practices

  • Use the weakest sufficient ordering. Relaxed for simple counters, Acquire/Release for producer-consumer patterns, SeqCst only when you need a total order visible to all threads.
  • Prefer xAtomicCasStrong over xAtomicCasWeak unless 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 xAtomicRelaxed as the failure ordering. If you need stronger failure ordering, use the raw xAtomicCas macro 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> in task.c for atomic_size_t but atomic.h macros everywhere else.

Comparison with Other Libraries

Featurexbase atomic.hC11 <stdatomic.h>C++ <atomic>Linux kernel atomics
StyleMacros over __atomic builtinsLanguage-level typesTemplate classInline functions + asm
Memory OrderExplicit parameterExplicit parameterExplicit parameterImplicit (varies)
TypesAny scalar (via pointer)_Atomic qualified typesstd::atomic<T>atomic_t, atomic64_t
CASxAtomicCasWeak/Strongatomic_compare_exchange_*compare_exchange_*cmpxchg
CompilerGCC ≥ 4.7, ClangC11C++11GCC (kernel)
PortabilityGCC/Clang onlyStandard C11Standard C++11Linux 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

MacroValueMeaning
xAtomicRelaxed__ATOMIC_RELAXEDNo ordering constraints. Only guarantees atomicity.
xAtomicConsume__ATOMIC_CONSUMEData-dependent ordering (rarely used in practice).
xAtomicAcquire__ATOMIC_ACQUIREPrevents reads/writes from being reordered before this operation.
xAtomicRelease__ATOMIC_RELEASEPrevents reads/writes from being reordered after this operation.
xAtomicAcqRel__ATOMIC_ACQ_RELCombines Acquire and Release.
xAtomicSeqCst__ATOMIC_SEQ_CSTFull sequential consistency. Most expensive.

Operation Macros

Load / Store

MacroExpansionDescription
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

MacroExpansionDescription
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 xAtomicRelaxed as the failure ordering. The success ordering is specified by the o parameter.

Arithmetic

MacroExpansionReturns
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

MacroExpansionReturns
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

  1. 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).

  2. 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.

  3. Fatal with Backtrace — When fatal = true, xLog() captures a stack trace via xBacktrace() before calling abort(). This provides immediate diagnostic information for unrecoverable errors.

  4. Bridge to xlog — The callback mechanism is designed to integrate with the higher-level xlog module. 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

MacroDefaultDescription
XLOG_BUF_SIZE512Format buffer size in bytes. Override before including the header.

Types

TypeDescription
xLogCallbackvoid (*)(const char *msg, const char *backtrace, void *userdata) — Log callback. backtrace is non-NULL only on fatal.

Functions

FunctionSignatureDescriptionThread Safety
xLogSetCallbackvoid xLogSetCallback(xLogCallback cb, void *userdata)Register (or clear with NULL) the current thread's log callback.Thread-local (each thread sets its own)
xLogvoid 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

  1. 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.

  2. xlog Integration — The xlog module registers its logger as the thread's callback via xLogSetCallback(), routing all internal libx messages through the async logging system.

  3. 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 backtrace parameter is NULL for non-fatal messages. Always check before using it.
  • Be aware of buffer truncation. Messages longer than XLOG_BUF_SIZE are truncated. Increase the size at compile time if needed.

Comparison with Other Libraries

Featurexbase log.hsyslogfprintf(stderr)GLib g_log
CallbackPer-threadGlobal handlerN/AGlobal handler
Thread SafetyThread-local (no locks)Thread-safe (kernel)Thread-safe (stdio lock)Thread-safe (global lock)
BacktraceBuilt-in on fatalNoNoOptional (G_DEBUG)
AllocationNone (stack buffer)None (kernel)None (stdio buffer)Heap (GString)
Fatal Handlingabort() with backtraceN/AN/Aabort() (G_LOG_FLAG_FATAL)
CustomizationPer-thread callbackopenlog()Redirect fdg_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

  1. 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.

  2. 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.

  3. Automatic Frame Skipping — Internal frames (xBacktrace → xBacktraceSkip → bt_capture) are automatically skipped so the output starts from the caller's perspective. The skip parameter allows additional frames to be skipped (useful when called through wrapper functions like xLog).

  4. 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

FunctionSignatureDescriptionThread Safety
xBacktraceint 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)
xBacktraceSkipint xBacktraceSkip(int skip, char *buf, size_t size)Capture the call stack, skipping skip additional frames beyond internal frames.Thread-safe

Parameters

ParameterDescription
skipNumber of additional frames to skip (0 = no extra skipping)
bufDestination buffer. May be NULL (returns 0).
sizeSize 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

  1. Fatal Error Diagnostics — xLog() captures a backtrace on fatal errors, providing immediate context for debugging crashes.

  2. Debug Assertions — Custom assertion macros can include xBacktrace() to show where the assertion failed.

  3. 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 -rdynamic on 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() calls malloc(), which is not async-signal-safe. libunwind is safer in this context.

Comparison with Other Libraries

Featurexbase backtrace.hglibc backtrace()libunwindBoost.StacktraceWindows CaptureStackBackTrace
PlatformmacOS + Linux + stubLinux (glibc)Linux + macOSCross-platformWindows
AccuracyBackend-dependentGood (glibc)ExcellentBackend-dependentGood
Symbol ResolutionBuilt-inbacktrace_symbols()unw_get_proc_name()Backend-dependentSymFromAddr()
AllocationNone (user buffer)malloc() for symbolsNoneHeapNone
Signal Safetylibunwind: yes, execinfo: noNo (malloc)YesNoYes
Frame SkippingBuilt-in (skip param)ManualManualManualFramesToSkip 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

BackendMacroPlatformQuality
libunwindX_HAS_LIBUNWINDLinux (with libunwind installed)Best — accurate unwinding, symbol + offset
execinfoX_HAS_EXECINFOmacOS, Linux (glibc)Good — requires -rdynamic on Linux for symbols
stub(fallback)AnyMinimal — 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 address
  • symbol+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 pointer
  • unw_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

  1. Thin Wrapper, Not a Framework — xSocket adds 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 via xSocketFd() for direct system calls.

  2. 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.

  3. Unified Callback — A single xSocketFunc callback handles all events (read, write, timeout). The mask parameter tells you what happened, and the xEvent_Timeout flag is OR'd with xEvent_Read or xEvent_Write to indicate which direction timed out.

  4. Implicit Event Loop — All functions obtain the event loop from thread-local storage via xEventLoopCurrent(). The caller must call xEventLoopEnter(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

TypeDescription
xSocketOpaque handle to an async socket
xSocketFuncvoid (*)(xSocket sock, xEventMask mask, void *arg) — Socket event callback

Functions

FunctionSignatureDescriptionThread Safety
xSocketCreatexSocket 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
xSocketCreateFromFdxSocket xSocketCreateFromFd(int fd, xEventMask mask, xSocketFunc callback, void *userp)Wrap an existing fd into an xSocket.Not thread-safe
xSocketDestroyvoid xSocketDestroy(xSocket sock)Cancel timers, remove from event loop, close fd, free handle. Safe with NULL.Not thread-safe
xSocketSetMaskxErrno xSocketSetMask(xSocket sock, xEventMask mask)Change the watched event mask.Not thread-safe
xSocketSetTimeoutxErrno xSocketSetTimeout(xSocket sock, int read_timeout_ms, int write_timeout_ms)Set idle timeouts. Pass 0 to cancel. Replaces previous settings.Not thread-safe
xSocketSetCallbackxErrno xSocketSetCallback(xSocket sock, xSocketFunc callback, void *userp)Replace the callback and user data.Not thread-safe
xSocketFdint xSocketFd(xSocket sock)Return the underlying fd, or -1 if NULL.Thread-safe (read-only)
xSocketMaskxEventMask xSocketMask(xSocket sock)Return the current event mask, or 0 if NULL.Thread-safe (read-only)

Callback Mask Values

MaskMeaning
xEvent_ReadSocket is readable
xEvent_WriteSocket is writable
xEvent_Timeout | xEvent_ReadRead idle timeout fired
xEvent_Timeout | xEvent_WriteWrite 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

  1. Network Servers — Create listening sockets, accept connections, and manage each client with its own xSocket + idle timeout. Dead connections are automatically detected.

  2. Protocol Clients — Build async clients (HTTP, Redis, etc.) that connect, send requests, and wait for responses with timeout protection.

  3. 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 EAGAIN in every callback.
  • Use idle timeouts for connection health. Set read_timeout_ms to detect dead peers. The timeout resets automatically on each read event.
  • Call xEventLoopEnter(loop) before using socket API. All socket functions use xEventLoopCurrent() internally to obtain the event loop.
  • Check the timeout direction. When xEvent_Timeout fires, check mask & xEvent_Read vs. mask & xEvent_Write to 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

Featurexbase socket.hPOSIX socket APIlibuv uv_tcp_tBoost.Asio
Non-blocking SetupAutomatic (SOCK_NONBLOCK + FD_CLOEXEC)Manual (fcntl)AutomaticAutomatic
Event RegistrationAutomatic (via xEventLoop)Manual (epoll_ctl / kevent)AutomaticAutomatic
Idle TimeoutBuilt-in (xSocketSetTimeout)Manual (timer + bookkeeping)Manual (uv_timer)Manual (deadline_timer)
Callback StyleSingle unified callback with maskN/A (blocking or manual poll)Separate read/write callbacksSeparate handlers
Raw fd AccessxSocketFd()Directuv_fileno()native_handle()
Buffered I/ONo (raw fd)NoYes (uv_read_start)Yes (async_read)
PlatformmacOS + LinuxPOSIXCross-platformCross-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:

  1. Resets idle timers — On xEvent_Read, cancels and re-arms the read timer. On xEvent_Write, cancels and re-arms the write timer.
  2. 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:

  1. socket(family, type, protocol) — On Linux/BSD with SOCK_CLOEXEC | SOCK_NONBLOCK, both flags are set in one syscall. On other platforms, fcntl() is used as a fallback.
  2. xEventAdd(fd, mask, trampoline, socket) — Registers with the event loop (obtained via xEventLoopCurrent()).
  3. Returns the opaque xSocket handle.

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

  1. 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.

  2. 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.

  3. Composable Helpers — Higher-level functions like xReadFull and xReadAll are built on top of xReader, so any object that provides a reader automatically gains these capabilities.

  4. 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

TypeDescription
xReaderAbstract reader — { ssize_t (*read)(void*, void*, size_t), void *ctx }
xWriterAbstract writer — { ssize_t (*writev)(void*, const struct iovec*, int), void *ctx }
xSeekerAbstract seeker — { off_t (*seek)(void*, off_t, int), void *ctx }
xCloserAbstract closer — { int (*close)(void*), void *ctx }

Functions

FunctionSignatureDescription
xReadssize_t xRead(xReader r, void *buf, size_t len)Single read; returns bytes read, 0 on EOF, -1 on error
xWritessize_t xWrite(xWriter w, const void *buf, size_t len)Write a contiguous buffer (wraps into single iovec)
xWritevssize_t xWritev(xWriter w, const struct iovec *iov, int iovcnt)Scatter-gather write
xSeekoff_t xSeek(xSeeker s, off_t offset, int whence)Reposition offset (SEEK_SET / SEEK_CUR / SEEK_END)
xCloseint xClose(xCloser c)Close the underlying resource
xReadFullssize_t xReadFull(xReader r, void *buf, size_t len)Read exactly len bytes, retrying on partial reads and EAGAIN/EINTR
xReadAllint 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:

FunctionReturnsDescription
xTcpConnReader(conn)xReaderReader bound to transport.read — equivalent to xTcpConnRecv
xTcpConnWriter(conn)xWriterWriter 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 xReadFull over manual loops when you need an exact number of bytes. It handles EAGAIN, EINTR, and partial reads correctly.
  • Always free() the buffer from xReadAll on success. On error, the function cleans up internally.
  • Use xWrite for simple writes, xWritev for multi-buffer writes. xWrite is 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, calling xRead on it will dereference a NULL function pointer.
  • Obtain adapters once, use many times. Since xTcpConnReader / xTcpConnWriter are value types, you can call them once at the start of a handler and reuse the result throughout.

Comparison with Other Libraries

Featurexbase io.hGo io.Reader/WriterPOSIX read/writeC++ std::iostream
AbstractionStruct (fn ptr + ctx)Interface (vtable)Raw syscallClass hierarchy
AllocationZero (stack value)Heap (interface value)N/AHeap (stream object)
ComposabilityVia helper functionsVia io.Copy, io.ReadAll, etc.Manual loopsVia stream operators
Scatter-GatherBuilt-in (xWritev)No (use io.MultiWriter)writev(2)No
Read-Until-EOFxReadAll (malloc'd buffer)io.ReadAll ([]byte)Manual loopstd::istreambuf_iterator
Error ModelReturn value (-1 + errno)(n, error) tupleReturn value (-1 + errno)Stream state flags

Implementation Details

Interface Structs

Each interface is a two-field struct:

InterfaceFunction PointerSemantics
xReaderssize_t (*read)(void *ctx, void *buf, size_t len)Returns bytes read, 0 on EOF, -1 on error
xWriterssize_t (*writev)(void *ctx, const struct iovec *iov, int iovcnt)Returns bytes written, -1 on error
xSeekeroff_t (*seek)(void *ctx, off_t offset, int whence)Returns resulting offset, -1 on error
xCloserint (*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

  1. 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.

  2. Independent Process Groups — Each child is placed in its own process group via setpgid(). This ensures that killpg() on timeout/cancellation kills the entire process tree (including any grandchildren), avoiding orphaned processes.

  3. 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.

  4. 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).

  5. Graceful Cancellation — xCommandExecutorCancel() sends SIGTERM first, then escalates to SIGKILL after 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

TypeDescription
xCommandOutputModeEnum: xCommandOutput_Capture, xCommandOutput_Stream, xCommandOutput_Discard
xCommandInputModeEnum: xCommandInput_Pipe (default), xCommandInput_Pty
xCommandConfConfiguration struct for a command invocation
xCommandResultResult struct populated on command completion
xCommandExecutorOpaque handle to a command executor
xCommandExecutorOutputFuncvoid (*)(xCommandExecutor, const char *data, size_t len, void *ud) — streaming output callback
xCommandExecutorDoneFuncvoid (*)(xCommandExecutor, const xCommandResult *result, void *ud) — completion callback

xCommandConf Fields

FieldTypeDescription
cmdconst char *Program path (required, searched in $PATH)
argvconst char **Argument vector (NULL-terminated, may be NULL)
envpconst char **Environment (NULL = inherit parent)
cwdconst char *Working directory (NULL = inherit)
timeout_msuint64_tTimeout in milliseconds (0 = no timeout)
stdout_capsize_tMax stdout bytes to capture (0 = unlimited)
stderr_capsize_tMax stderr bytes to capture (0 = unlimited, ignored in PTY mode)
stdout_modexCommandOutputModeHow to handle stdout
stderr_modexCommandOutputModeHow to handle stderr (ignored in PTY mode)
input_modexCommandInputModexCommandInput_Pipe (default) or xCommandInput_Pty

xCommandResult Fields

FieldTypeDescription
exit_codeintExit status (valid if signaled == 0)
signaledintNon-zero if killed by signal; holds signal number
timed_outintNon-zero if killed due to timeout
stdout_bufconst char *Captured stdout (NULL in Stream/Discard mode)
stdout_lensize_tLength of captured stdout
stderr_bufconst char *Captured stderr (NULL in Stream/Discard/PTY mode)
stderr_lensize_tLength of captured stderr
elapsed_msuint64_tWall-clock duration from spawn to exit
pty_fdintPTY master fd (valid while running, -1 otherwise)

Functions

FunctionSignatureDescriptionThread Safety
xCommandExecutorCreatexCommandExecutor xCommandExecutorCreate(xEventLoop loop)Create a command executor bound to the given event loop. Registers a SIGCHLD watch.Not thread-safe
xCommandExecutorDestroyvoid xCommandExecutorDestroy(xCommandExecutor exec)Destroy an executor. If running, kills the child process group (SIGKILL) and waits. NULL-safe.Not thread-safe
xCommandExecutorSubmitxErrno 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)
xCommandExecutorCancelxErrno xCommandExecutorCancel(xCommandExecutor exec)Cancel a running command (SIGTERM → SIGKILL after 5s). Returns xErrno_InvalidState if not running.Not thread-safe
xCommandExecutorPidint xCommandExecutorPid(xCommandExecutor exec)Return the PID of the running child, or -1 if idle. NULL-safe.Thread-safe (atomic)
xCommandExecutorIsRunningint xCommandExecutorIsRunning(xCommandExecutor exec)Return non-zero if a command is currently running. NULL-safe.Thread-safe (atomic)
xCommandExecutorPtyFdint 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

  1. Shell Command Execution — Run system commands (e.g., git, docker, build tools) asynchronously and capture their output without blocking the event loop.

  2. 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).

  3. Interactive Programs — PTY mode enables interaction with programs that require a terminal (e.g., SSH sessions, REPLs, text editors with colored output).

  4. Build/Deploy Automation — Run build scripts with timeout enforcement. If a build hangs, it is automatically killed after the configured timeout.

  5. 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_done fires, the same xCommandExecutor can be used for the next command. There is no need to destroy and recreate it.

  • Use stdout_cap / stderr_cap to 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 \r before \n. Strip \r if you need clean output.

  • Don't call xCommandExecutorSubmit() from the on_done callback. Although the executor is idle at that point, calling xCommandExecutorSubmit() inside on_done will start a new command immediately while the event loop is still processing I/O events from the previous one. Instead, use xEventLoopPost() to defer the next run.

Comparison with Other Libraries

Featurexbase command.hpopen() / pclose()posix_spawn()libuv uv_spawn
Async / Event-LoopYes (xEventLoop)No (blocking)No (blocking wait)Yes (uv_loop)
stdout + stderrSeparate capture/streamstdout onlyManual pipe setupSeparate pipes
StreamingYes (callbacks)Line-by-line onlyManualYes (callbacks)
PTY SupportYes (xCommandInput_Pty)NoNoNo (external)
TimeoutBuilt-in (timeout_ms)ManualManualManual (uv_timer)
CancellationxCommandExecutorCancel() (SIGTERM→SIGKILL)kill() + pclose()kill() + waitpid()uv_process_kill()
Process GroupsYes (independent via setpgid)NoNoNo (manual)
PlatformmacOS + LinuxPOSIXPOSIXCross-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

Modestdout/stderr behaviorxCommandResult fields
xCommandOutput_CaptureAccumulate into internal buffersstdout_buf / stderr_buf + stdout_len / stderr_len populated
xCommandOutput_StreamDeliver chunks via callbacksstdout_buf / stderr_buf are NULL; use on_stdout / on_stderr callbacks
xCommandOutput_DiscardRedirect to /dev/nullstdout_buf / stderr_buf are NULL

Input Modes

ModeDescription
xCommandInput_PipeDefault: stdin is inherited from the parent process (no PTY). stdout and stderr are captured/streamed separately via pipes.
xCommandInput_PtyAllocate 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_mode is effectively ignored — there is no separate stderr stream.
  • In Capture mode, all output goes to result.stdout_buf only; result.stderr_buf is always NULL.
  • The on_stderr callback is never invoked.
  • result.pty_fd is set to the master fd while the command is running, allowing the caller to write to the child's stdin. It is set to -1 after 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

  1. Platform-Native First — Prefers kernel-level CSPRNG APIs before falling back to file-based sources. This avoids file descriptor exhaustion in high-throughput scenarios.

  2. Strict Validation — Returns xErrno_InvalidArg if buf is NULL. No silent failures.

  3. Loop for Success — All backends loop on EINTR and partial reads. The function does not return until the buffer is fully populated or a fatal error occurs.

  4. 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

FunctionSignatureDescription
xRandomBytesxErrno xRandomBytes(void *buf, size_t len)Fill buf with len cryptographically secure random bytes.

Parameters

ParameterDescription
bufDestination buffer. Must not be NULL.
lenNumber of random bytes to generate. 0 is valid (no-op).

Return Values

ReturnDescription
xErrno_OkSuccess — buf filled with len random bytes.
xErrno_InvalidArgbuf is NULL.
xErrno_SysErrorPlatform 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_Ok guarantees the buffer is fully populated.
  • Zero is valid — xRandomBytes(buf, 0) is a no-op and always succeeds.
  • No CSRF tokens from xRandomBytes alone — 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.h depends on xRandomBytes to 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

  1. 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 into argv memory, matching getopt's optarg convention — no hidden allocations on the hot path.

  2. Never Calls exit() — The parser returns a structured xErrno; the caller decides what to do. --help / --version are surfaced as xErrno_Again after the text is printed on stdout, so applications stay in full control of their exit path.

  3. 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 (--fi for --file) is deliberately omitted: exact match only, to keep scripts forward-compatible when new flags are added.

  4. 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.

  5. Built-in Validation — Integer flags accept decimal, 0x hex, 0b binary, and 0-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

TypeDescription
xFlagSetOpaque handle representing a set of registered flags
xFlagAttrPer-flag attribute bitmask (see Flag Attributes)

Lifecycle

FunctionSignatureDescription
xFlagSetCreatexFlagSet 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
xFlagSetDestroyvoid xFlagSetDestroy(xFlagSet set)Destroy a flag set and release owned memory. NULL-safe. Does not touch caller-owned storage
xFlagSetEpilogvoid xFlagSetEpilog(xFlagSet set, const char *text)Append an epilog section printed after the options block (e.g. "Examples:" or "Notes:"). Pass NULL to clear
xFlagSetVersionvoid 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.

FunctionSignatureDescription
xFlagAddStringxErrno 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://...)
xFlagAddBoolxErrno xFlagAddBool(xFlagSet set, const char *name, char shortc, const char *help, bool *storage, int attrs)Boolean switch; presence → true; takes no argument
xFlagAddIntxErrno xFlagAddInt(xFlagSet set, const char *name, char shortc, const char *meta, const char *help, int *storage, int def, int attrs)Signed 32-bit integer
xFlagAddI64xErrno 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
xFlagAddU64xErrno 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
xFlagAddDoublexErrno xFlagAddDouble(xFlagSet set, const char *name, char shortc, const char *meta, const char *help, double *storage, double def, int attrs)Double-precision float
xFlagAddChoicexErrno 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
xFlagAddCounterxErrno 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:

ParameterMeaning
nameLong name without dashes (e.g. "file"). May be NULL for short-only flags. Must be unique
shortcSingle-character short name (e.g. 'f'). Pass 0 for long-only flags. Must be unique
metaPlaceholder shown in usage (e.g. "FILE"). NULL → the flag takes no argument in usage formatting. Ignored by xFlagAddBool / xFlagAddCounter
helpOne-line description (NULL → empty)
storagePointer to caller-owned variable filled on successful parse. Must outlive xFlagParse()
defDefault value written to *storage before parsing; also shown as [default: ...] in usage
attrsBitmask of xFlagAttr values

Positional Registration

FunctionSignatureDescription
xFlagAddPositionalxErrno 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
xFlagAddPositionalTailxErrno 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

FunctionSignatureDescription
xFlagParsexErrno 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()
xFlagPrintUsagevoid 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)
xFlagPrintHelpvoid 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 iterate argv yourself.

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

  1. Example / Demo Programs — Replace getopt_long() boilerplate in examples/ with a few xFlagAdd* calls and get a formatted help screen for free.

  2. CLI Tools — Small libx-based utilities (benchmarks, migration scripts, diagnostic tools) that want conventional POSIX/GNU syntax without pulling in argp or a heavyweight parser.

  3. Application Front-Ends — Projects under cli/ that wrap libx modules into standalone binaries can use flag.h for their startup configuration, and later upgrade to xcli once subcommand trees are needed.

  4. Configuration Overrides — Parse command-line overrides before loading a config file; xFlagAttr_Required marks mandatory knobs and [default: ...] documents the rest in --help.

Best Practices

  • Always handle xErrno_Again. This signals that --help / --version was processed. The parser has already written to stdout; the caller should exit 0 cleanly.

  • free() the error string. On failure, *err_out is 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 outlive main. Parsed string values point into argv. 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 xFlagAddChoice over free-form strings. The parser does the enum validation for you and shows the allowed values in --help, saving you a strcmp ladder and giving users a self-documenting interface.

  • Don't depend on prefix matching. --fil will 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_Hidden sparingly. Reserve it for internal / debug / deprecated flags. A hidden flag that users need to discover is a support-channel footgun.

Comparison with Other Parsers

Featurexbase flag.hgetopt(3)getopt_long(3)argp (glibc)
POSIX short / GNU longBothShort onlyBothBoth
Auto-generated --helpYesNoNoYes
Typed storage (bool, int, …)YesNo (string only)No (string only)Partial (via parser fn)
Choice validationYesNoNoManual
Counter flags (-vvv)Built-inManualManualManual
Default values in helpYesNoNoNo
Positional + tail supportYesManualManualVia parser fn
Never calls exit()YesYesYesNo (default handlers)
Subcommand treesNo (future xcli)NoNoYes
Environment / config fallbackNoNoNoNo
PlatformmacOS + LinuxPOSIXGNUglibc

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

FormMeaning
-f valueShort flag with a separate argument
-fvalueShort flag with a glued argument
-abcBundled no-arg shorts; the last one may take an argument
--file valueLong flag with a separate argument
--file=valueLong flag with an =-form argument
--flagLong 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 xcli module)
  • Environment / config-file fallback
  • Shell-completion generation
  • Long-name prefix matching (--fi for --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.

AttributeMeaning
xFlagAttr_NoneDefault (no attribute)
xFlagAttr_RequiredParse fails with xErrno_InvalidArg if the flag is absent
xFlagAttr_HiddenOmit from --help output (useful for internal/debug flags)
xFlagAttr_MultiAllow repetition; each occurrence is collected into an internal array. Only meaningful for string flags

Help / Version Handling

  • --help / -h are always recognised (unless the caller has already registered h).
  • --version / -V are recognised only after xFlagSetVersion() has been called (and only if those names are free).
  • Both cause xFlagParse() to print to stdout and return xErrno_Again. No flag storage is written.

Integer Parsing

xFlagAddInt / xFlagAddI64 / xFlagAddU64 accept:

PrefixBase
0x / 0XHexadecimal (e.g. -n 0xff)
0b / 0BBinary (e.g. -n 0b1010)
0 + digitOctal (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 stringStorage pointers (bool *, const char **, …)
Arrays collected for xFlagAttr_Multichoices array for xFlagAddChoice (must outlive the set)
Tail positional array allocated by xFlagAddPositionalTailargv 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

  1. 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.

  2. Flexible Array Member Layout — Both xBuffer and xRingBuffer allocate header + data in a single malloc() call using C99 flexible array members. This eliminates pointer indirection and improves cache locality.

  3. Reference-Counted Block Sharing — xIOBuffer uses reference-counted blocks that can be shared across multiple buffers. This enables zero-copy split and append operations critical for high-performance network protocols.

  4. I/O Integration — All three types provide ReadFd/WriteFd helpers that handle EINTR retries 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

HeaderTypeDescriptionDoc
buf.hxBufferLinear auto-growing byte buffer with flexible array member layoutbuf.md
ring.hxRingBufferFixed-size circular buffer with power-of-2 bitmask indexingring.md
io.hxIOBufferReference-counted block-chain I/O buffer with zero-copy operationsio.md

How to Choose

CriterionxBufferxRingBufferxIOBuffer
Memory layoutContiguousContiguous (circular)Non-contiguous (block chain)
GrowthAuto-growing (2x realloc)Fixed size (never grows)Auto-growing (new blocks)
Best forAccumulating variable-length dataFixed-capacity producer-consumerHigh-throughput network I/O
Zero-copy splitNoNoYes
Zero-copy appendNoNoYes (between xIOBuffers)
Scatter-gather I/ONo (single buffer)Yes (up to 2 iovecs)Yes (N iovecs)
Memory overheadMinimal (1 allocation)Minimal (1 allocation)Per-block overhead + ref array
Thread safetyNot thread-safeNot thread-safeBlock 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 — xIOBuffer uses atomic.h for lock-free block pool management and reference counting.
  • xhttp — The HTTP client (client.h) uses xIOBuffer for response body accumulation and SSE stream parsing.
  • xlog — The async logger (logger.h) may use xBuffer for 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

  1. Single Allocation — Header and data live in one contiguous block (struct + flexible array member). This means one malloc(), one free(), and excellent cache locality.

  2. Handle Indirection — Because realloc() may relocate the entire object, write APIs take xBuffer *bufp (pointer to handle) so the caller's handle stays valid after growth.

  3. 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.

  4. 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

FunctionSignatureDescriptionThread Safety
xBufferCreatexBuffer xBufferCreate(size_t initial_cap)Create a buffer. Min capacity is 64.Not thread-safe
xBufferDestroyvoid xBufferDestroy(xBuffer buf)Free the buffer. NULL is a no-op.Not thread-safe
xBufferResetvoid xBufferReset(xBuffer buf)Discard all data, keep memory.Not thread-safe

Write

FunctionSignatureDescriptionThread Safety
xBufferAppendxErrno xBufferAppend(xBuffer *bufp, const void *data, size_t len)Append bytes, growing if needed.Not thread-safe
xBufferAppendStrxErrno xBufferAppendStr(xBuffer *bufp, const char *str)Append a C string (excluding NUL).Not thread-safe
xBufferReservexErrno xBufferReserve(xBuffer *bufp, size_t additional)Ensure at least additional writable bytes.Not thread-safe

Read

FunctionSignatureDescriptionThread Safety
xBufferDataconst void *xBufferData(xBuffer buf)Pointer to readable data. Valid until next mutation.Not thread-safe
xBufferLensize_t xBufferLen(xBuffer buf)Number of readable bytes.Not thread-safe
xBufferCapsize_t xBufferCap(xBuffer buf)Total allocated capacity.Not thread-safe
xBufferWritablesize_t xBufferWritable(xBuffer buf)Writable bytes (cap - wpos).Not thread-safe
xBufferConsumevoid xBufferConsume(xBuffer buf, size_t n)Advance read position by n bytes.Not thread-safe
xBufferCompactvoid xBufferCompact(xBuffer buf)Move unread data to front, maximize writable space.Not thread-safe

I/O Helpers

FunctionSignatureDescriptionThread Safety
xBufferReadFdssize_t xBufferReadFd(xBuffer *bufp, int fd)Read from fd into buffer (ensures 4KB space).Not thread-safe
xBufferWriteFdssize_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

  1. HTTP Response Accumulation — Accumulate response body chunks of unknown total size. The auto-growing behavior handles variable-length responses.

  2. Protocol Parsing — Append incoming data, parse complete messages from the front, consume parsed bytes. The compact operation reclaims space without reallocation.

  3. Log Message Formatting — Build log messages incrementally with multiple append calls before flushing.

Best Practices

  • Always pass &buf to write APIs. Functions that may grow the buffer take xBuffer *bufp because realloc() may relocate the object.
  • Call xBufferCompact() periodically if you consume data incrementally. This avoids unnecessary reallocation by reclaiming consumed space.
  • Check return values. xBufferAppend() and xBufferReserve() return xErrno_NoMemory on allocation failure.
  • Don't cache xBufferData() pointers across mutating calls. Any append/reserve/compact may invalidate the pointer.

Comparison with Other Libraries

Featurexbuf buf.hGo bytes.BufferRust Vec<u8>C++ std::vector<char>
LayoutHeader + data in one allocation (FAM)Separate header + sliceHeap-allocated arrayHeap-allocated array
Growth2x realloc + compact2x (with copy)2x (with copy)Implementation-defined
Read/Write cursorsYes (rpos/wpos)Yes (read offset)No (manual tracking)No (manual tracking)
CompactBuilt-in (xBufferCompact)Built-in (implicit)ManualManual
I/O helpersReadFd/WriteFdReadFrom/WriteToVia Read/Write traitsNo
Handle invalidationCaller updates via *bufpGC handlesBorrow checkerIterator 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

BenchmarkChunk SizeTime (ns)CPU (ns)Throughput
BM_Buffer_Append164,7764,7763.1 GiB/s
BM_Buffer_Append644,4004,40013.5 GiB/s
BM_Buffer_Append2567,8927,89230.2 GiB/s
BM_Buffer_Append1,02421,83421,81143.7 GiB/s
BM_Buffer_Append4,09691,02990,95841.9 GiB/s
BM_Buffer_AppendConsume644,9994,99911.9 GiB/s
BM_Buffer_AppendConsume2568,2418,24028.9 GiB/s
BM_Buffer_AppendConsume1,02422,85922,85941.7 GiB/s

Key Observations:

  • Append throughput peaks at ~44 GiB/s for 1KB chunks, limited by memcpy bandwidth 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 memcpy cost.

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

OperationTime ComplexityNotes
xBufferAppendAmortized O(1) per byteMay trigger compact or realloc
xBufferConsumeO(1)Advances read position
xBufferCompactO(n)memmove of unread data
xBufferDataO(1)Returns data + rpos
xBufferLenO(1)Returns wpos - rpos
xBufferReadFdO(1)Single read() syscall
xBufferWriteFdO(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

  1. 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.

  2. Power-of-Two Masking — The internal capacity is always a power of two. Index computation uses head & mask instead of head % cap, which is significantly faster on most architectures.

  3. Monotonic Cursors — head (write) and tail (read) grow monotonically and never wrap. The actual array index is computed via bitmask. This simplifies the full/empty distinction: head - tail gives the exact readable byte count.

  4. Single Allocation — Like xBuffer, the header and data area are allocated together using a flexible array member.

  5. Scatter-Gather I/O — The ring buffer provides ReadIov/WriteIov helpers that fill iovec arrays for efficient readv()/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

FunctionSignatureDescriptionThread Safety
xRingBufferCreatexRingBuffer xRingBufferCreate(size_t min_cap)Create a ring buffer. Capacity rounded up to power of 2.Not thread-safe
xRingBufferDestroyvoid xRingBufferDestroy(xRingBuffer rb)Free the ring buffer. NULL is a no-op.Not thread-safe
xRingBufferResetvoid xRingBufferReset(xRingBuffer rb)Discard all data, keep memory.Not thread-safe

Query

FunctionSignatureDescriptionThread Safety
xRingBufferLensize_t xRingBufferLen(xRingBuffer rb)Readable bytes.Not thread-safe
xRingBufferCapsize_t xRingBufferCap(xRingBuffer rb)Total capacity.Not thread-safe
xRingBufferWritablesize_t xRingBufferWritable(xRingBuffer rb)Writable bytes.Not thread-safe
xRingBufferEmptybool xRingBufferEmpty(xRingBuffer rb)True if no readable data.Not thread-safe
xRingBufferFullbool xRingBufferFull(xRingBuffer rb)True if no writable space.Not thread-safe

Write

FunctionSignatureDescriptionThread Safety
xRingBufferWritesize_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

FunctionSignatureDescriptionThread Safety
xRingBufferReadsize_t xRingBufferRead(xRingBuffer rb, void *out, size_t len)Read and consume bytes. Returns actual count.Not thread-safe
xRingBufferPeeksize_t xRingBufferPeek(xRingBuffer rb, void *out, size_t len)Read without consuming.Not thread-safe
xRingBufferDiscardsize_t xRingBufferDiscard(xRingBuffer rb, size_t n)Discard bytes without copying.Not thread-safe

I/O Helpers

FunctionSignatureDescriptionThread Safety
xRingBufferReadIovint xRingBufferReadIov(xRingBuffer rb, struct iovec iov[2])Fill iovecs with readable regions (for writev).Not thread-safe
xRingBufferWriteIovint xRingBufferWriteIov(xRingBuffer rb, struct iovec iov[2])Fill iovecs with writable regions (for readv).Not thread-safe
xRingBufferReadFdssize_t xRingBufferReadFd(xRingBuffer rb, int fd)Read from fd using readv().Not thread-safe
xRingBufferWriteFdssize_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

  1. 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.

  2. Logging Ring Buffer — Capture the last N bytes of log output, automatically discarding old data when the buffer wraps.

  3. 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/WriteFd use readv()/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

Featurexbuf ring.hLinux kfifoBoost circular_bufferDPDK rte_ring
CapacityFixed, power-of-2Fixed, power-of-2Fixed, any sizeFixed, power-of-2
IndexingBitmaskBitmaskModuloBitmask
LayoutFAM (single alloc)Separate allocHeap arrayHuge pages
Thread SafetyNot thread-safeSingle-producer/single-consumerNot thread-safeMulti-producer/multi-consumer
I/O Helpersreadv/writevkfifo_to_user/kfifo_from_userNoNo (packet-oriented)
LanguageC99C (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

BenchmarkSizeTime (ns)CPU (ns)Throughput
BM_Ring_WriteRead646.056.0519.7 GiB/s
BM_Ring_WriteRead25616.816.828.4 GiB/s
BM_Ring_WriteRead1,02427.427.469.6 GiB/s
BM_Ring_WriteRead4,09699.299.276.9 GiB/s
BM_Ring_Throughput4,09622522517.0 GiB/s
BM_Ring_Throughput16,38480680618.9 GiB/s
BM_Ring_Throughput65,5363,1983,19819.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 memcpy for 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

OperationTime ComplexityNotes
xRingBufferWriteO(n)Up to 2 memcpy calls
xRingBufferReadO(n)Up to 2 memcpy calls
xRingBufferPeekO(n)Like Read but doesn't advance tail
xRingBufferDiscardO(1)Just advances tail
xRingBufferLenO(1)head - tail
xRingBufferReadFdO(1)Single readv() syscall
xRingBufferWriteFdO(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

  1. 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.

  2. Reference Counting — Each xIOBlock is reference-counted. Multiple xIOBuffer instances can share the same block (e.g., after a Cut operation). Blocks are freed (returned to pool) when the last reference is released.

  3. Zero-Copy Operations — xIOBufferAppendIOBuffer() transfers block references without copying data. xIOBufferCut() splits a buffer by adjusting offsets and sharing blocks at the boundary.

  4. Lock-Free Block Pool — Released blocks are returned to a global Treiber stack (lock-free) for reuse, avoiding malloc/free overhead in steady state.

  5. 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

MacroDefaultDescription
XIOBUFFER_BLOCK_SIZE8192Block data size in bytes
XIOBUFFER_INLINE_REFS8Inline ref array capacity

Block API

FunctionSignatureDescriptionThread Safety
xIOBlockAcquirexIOBlock *xIOBlockAcquire(void)Get a block from pool (or malloc). refs=1.Thread-safe (lock-free pool)
xIOBlockRetainvoid xIOBlockRetain(xIOBlock *blk)Increment refcount.Thread-safe (atomic)
xIOBlockReleasevoid xIOBlockRelease(xIOBlock *blk)Decrement refcount; return to pool at 0.Thread-safe (atomic + lock-free pool)
xIOBlockPoolWarmupxErrno xIOBlockPoolWarmup(size_t n)Pre-allocate n blocks into pool.Thread-safe
xIOBlockPoolDrainvoid xIOBlockPoolDrain(void)Free all pooled blocks. Call at shutdown.Not thread-safe (no concurrent use)

IOBuffer Lifecycle

FunctionSignatureDescriptionThread Safety
xIOBufferInitvoid xIOBufferInit(xIOBuffer *io)Initialize an empty IOBuffer.Not thread-safe
xIOBufferDeinitvoid xIOBufferDeinit(xIOBuffer *io)Release all refs and free ref array.Not thread-safe
xIOBufferResetvoid xIOBufferReset(xIOBuffer *io)Release all refs, keep ref array.Not thread-safe

IOBuffer Query

FunctionSignatureDescriptionThread Safety
xIOBufferLensize_t xIOBufferLen(const xIOBuffer *io)Total readable bytes.Not thread-safe
xIOBufferEmptybool xIOBufferEmpty(const xIOBuffer *io)True if no data.Not thread-safe
xIOBufferRefCountsize_t xIOBufferRefCount(const xIOBuffer *io)Number of block refs.Not thread-safe

IOBuffer Write

FunctionSignatureDescriptionThread Safety
xIOBufferAppendxErrno xIOBufferAppend(xIOBuffer *io, const void *data, size_t len)Append bytes (allocates blocks as needed).Not thread-safe
xIOBufferAppendStrxErrno xIOBufferAppendStr(xIOBuffer *io, const char *str)Append C string.Not thread-safe
xIOBufferAppendIOBufferxErrno xIOBufferAppendIOBuffer(xIOBuffer *io, xIOBuffer *other)Zero-copy: move all refs from other.Not thread-safe

IOBuffer Read

FunctionSignatureDescriptionThread Safety
xIOBufferReadsize_t xIOBufferRead(xIOBuffer *io, void *out, size_t len)Copy and consume bytes.Not thread-safe
xIOBufferCutsize_t xIOBufferCut(xIOBuffer *io, xIOBuffer *dst, size_t n)Zero-copy split: move first n bytes to dst.Not thread-safe
xIOBufferConsumesize_t xIOBufferConsume(xIOBuffer *io, size_t n)Discard first n bytes.Not thread-safe
xIOBufferCopyTosize_t xIOBufferCopyTo(const xIOBuffer *io, void *out)Linearize: copy all data to contiguous buffer.Not thread-safe

IOBuffer I/O

FunctionSignatureDescriptionThread Safety
xIOBufferReadIovint xIOBufferReadIov(const xIOBuffer *io, struct iovec *iov, int max_iov)Fill iovecs for writev().Not thread-safe
xIOBufferReadFdssize_t xIOBufferReadFd(xIOBuffer *io, int fd)Read from fd into IOBuffer.Not thread-safe
xIOBufferWriteFdssize_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

  1. HTTP Response Body — The xhttp module uses xIOBuffer to accumulate response chunks from libcurl without copying between buffers.

  2. Protocol Framing — Use xIOBufferCut() to split headers from body in a zero-copy fashion, then process each part independently.

  3. Data Pipeline — Chain multiple processing stages that each append to or cut from xIOBuffer instances, 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 than xIOBufferRead() 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

Featurexbuf io.hbrpc IOBufNetty ByteBufGo bytes.Buffer
ArchitectureBlock-chain (ref array)Block-chain (linked list)Composite bufferContiguous slice
Block Size8KB (configurable)8KBConfigurableN/A
Reference CountingAtomic (per block)Atomic (per block)Atomic (per buffer)GC
Zero-Copy SplitxIOBufferCutcutnsliceNo
Zero-Copy AppendxIOBufferAppendIOBufferappend(IOBuf)addComponentNo
Block PoolTreiber stack (lock-free)Thread-local + globalArena allocatorN/A
Scatter-Gather I/Owritev via ReadIovwritev via pappendnioBuffersNo
Inline Optimization8 inline refsNoNoN/A
LanguageC99C++JavaGo

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

BenchmarkSizeTime (ns)CPU (ns)Throughput
BM_IOBuffer_Append643,7203,72016.0 GiB/s
BM_IOBuffer_Append2567,5697,56831.5 GiB/s
BM_IOBuffer_Append1,02422,34122,34042.7 GiB/s
BM_IOBuffer_Append4,09679,79679,79447.8 GiB/s
BM_IOBuffer_Append8,192187,167187,16540.8 GiB/s
BM_IOBuffer_AppendConsume645,2305,23011.4 GiB/s
BM_IOBuffer_AppendConsume2568,2328,23229.0 GiB/s
BM_IOBuffer_AppendConsume1,02423,04023,04041.4 GiB/s
BM_IOBuffer_Cut8,19216716745.6 GiB/s
BM_IOBuffer_Cut65,5361,6511,65137.0 GiB/s
BM_IOBuffer_Cut262,1448,1228,12230.1 GiB/s
BM_IOBuffer_AppendIOBuffer1,0243,1963,19629.8 GiB/s
BM_IOBuffer_AppendIOBuffer4,0969,3079,30741.0 GiB/s
BM_IOBuffer_AppendIOBuffer8,19217,60417,60243.3 GiB/s
BM_IOBuffer_BlockPool—8.918.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:

  1. Fully consumed refs — Ownership transfers directly (no refcount change).
  2. 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):

  1. First tries to fill the tail block's remaining space (avoids allocating a new block for small appends).
  2. Allocates new blocks for remaining data, each up to XIOBUFFER_BLOCK_SIZE bytes.

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

  1. 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 via X_TLS_BACKEND, keeping runtime overhead at zero and the public interface stable.

  2. 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.

  3. 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.

  4. Compile-Time Static Assertions — Each backend implementation uses _Static_assert to verify at compile time that the opaque buffer is large enough for its internal state, catching size mismatches before they become runtime bugs.

  5. Consistent Error Handling — All functions return xErrno codes and validate arguments defensively, following the same error convention used throughout libx.

  6. Generic HMAC via Vtable — The HMAC implementation is hash-agnostic, driven by an xHashVtable that 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_BACKENDSHA-1 / SHA-256 BackendExternal Dependency
opensslOpenSSL EVP APIlibssl, libcrypto
mbedtlsmbedTLSlibmbedtls
autoAuto-detect: OpenSSL → mbedTLS → builtinBest available
(anything else)Pure-C builtinNone

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

HeaderDescription
sha1.hSHA-1 hash — one-shot and streaming API with pluggable backend
sha256.hSHA-256 hash — one-shot and streaming API with pluggable backend
md5.hMD5 hash — one-shot and streaming API (pure C, RFC 1321)
crc32.hCRC-32 checksum — one-shot API (pure C, ISO 3309)
hmac.hGeneric HMAC — one-shot and streaming API (RFC 2104), works with any xHashVtable
hmac_sha1.hHMAC-SHA1 convenience wrapper
hmac_sha256.hHMAC-SHA256 convenience wrapper
hmac_md5.hHMAC-MD5 convenience wrapper
uuid.hUUID generation (RFC 4122 / RFC 9562) — v4 random, v7 time-ordered, v5 namespace+SHA-1 (docs)

Hash Constants

ConstantValueDescription
XCRYPTO_SHA1_DIGEST_SIZE20SHA-1 digest length in bytes
XCRYPTO_SHA1_BLOCK_SIZE64SHA-1 internal block size in bytes
XCRYPTO_SHA256_DIGEST_SIZE32SHA-256 digest length in bytes
XCRYPTO_SHA256_BLOCK_SIZE64SHA-256 internal block size in bytes
XCRYPTO_MD5_DIGEST_SIZE16MD5 digest length in bytes
XCRYPTO_MD5_BLOCK_SIZE64MD5 internal block size in bytes

Hash Functions

FunctionDescription
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

FunctionDescription
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 xErrno error codes, XDEF_STRUCT, and XCAPI macros.
  • xhttp — The WebSocket handshake (RFC 6455) requires SHA-1 to compute the Sec-WebSocket-Accept header.
  • 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 xRandomBytes for 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 xSha1 from xcrypto.

UUIDs are 16-byte value types (xUuid). They are stack-allocatable with no lifetime management. All generation functions return by value.

Design Philosophy

  1. Value Type — xUuid is a struct of 16 bytes. No heap allocation, no opaque handle, no destroy function. Safe to copy, assign, and pass by value.

  2. Cryptographic Randomness — v4 and v7 use xRandomBytes (kernel CSPRNG or /dev/urandom), not rand() or a PRNG. Suitable for security-sensitive identifiers.

  3. Time-Ordered by Default — v7 is recommended for database primary keys. The 48-bit millisecond timestamp sorts chronologically, reducing index fragmentation.

  4. No UUID v1 — MAC address + timestamp UUIDs (v1) leak hardware identity and clock sequence. Not implemented.

  5. Consistent String Format — xUuidToString always produces lowercase with hyphens (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). xUuidFromString is 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

FunctionSignatureDescription
xUuidV4xUuid xUuidV4(void)Generate a random UUID (v4). Version bits: 4 in octet 6, 10xx in octet 8.
xUuidV7xUuid xUuidV7(void)Generate a time-ordered UUID (v7). 48-bit Unix ms timestamp, 74 random bits.
xUuidV5xUuid xUuidV5(xUuid ns, const char *name)Generate a namespace + name SHA-1 UUID (v5). Deterministic.

Formatting

FunctionSignatureDescription
xUuidToStringvoid xUuidToString(xUuid uuid, char buf[37])Format as lowercase hyphenated string. buf must be at least 37 bytes.
xUuidFromStringxErrno xUuidFromString(const char *str, xUuid *out)Parse a UUID string. Case-insensitive, any hyphenation accepted. Returns xErrno_Ok or xErrno_Invalid.

Comparison

FunctionSignatureDescription
xUuidCompareint xUuidCompare(xUuid a, xUuid b)Lexicographic byte comparison. Returns <0, 0, or >0.
xUuidIsNilbool xUuidIsNil(xUuid uuid)Returns true if all 16 bytes are zero.

Namespace UUIDs

FunctionReturns
xUuidNamespaceDns()const xUuid * — 6ba7b810-9dad-11d1-80b4-00c04fd430c8
xUuidNamespaceUrl()const xUuid * — 6ba7b811-9dad-11d1-80b4-00c04fd430c8

Types

TypeDescription
xUuidXDEF_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 xUuidFromString return value — Malformed strings produce xErrno_Invalid.
  • Allocate 37 bytes for xUuidToString output — 32 hex digits + 4 hyphens + NUL.

Relationship with Other Modules

  • xbase — Uses xRandomBytes for v4 and v7 random bits, and xMonoMs() for v7 timestamps.
  • xcrypto — Uses xSha1() from sha1.h for 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

  1. 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.

  2. 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.

  3. Shared TLS Types — xTlsConf is a plain data structure shared across modules. It decouples TLS configuration from any specific TLS backend (OpenSSL, mbedTLS).

  4. Async TCP with Transport Abstraction — xTcpConnect chains DNS → connect → optional TLS handshake into a single async operation. xTcpConn wraps an xSocket + xTransport vtable, providing Recv/Send/SendIov helpers 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

HeaderComponentDescriptionDoc
url.hxUrlLightweight URL parserurl.md
dns.hxDnsResolveAsync DNS resolutiondns.md
tls.hxTlsConfShared TLS config typestls.md
tcp.hxTcpConn / xTcpConnect / xTcpListenerAsync TCP connection, connector & listenertcp.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 xEventLoop for thread-pool offload and uses atomic.h for the cancellation flag.
  • xhttp — The HTTP client uses xUrl for URL parsing, xDnsResolve for hostname resolution, and xTlsConf for TLS configuration. The WebSocket client supports both xTlsConf and a shared xTlsCtx for wss:// connections. See the TLS Deployment Guide for end-to-end examples.
  • WebSocket — The WebSocket client uses xUrl to parse ws:// and wss:// URLs, and optionally accepts a shared xTlsCtx to 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

  1. Single Copy, Zero Per-Field Allocation — xUrlParse() calls strdup() once. All output fields point into this copy, avoiding per-component heap allocations.

  2. Pointer+Length Pairs — Fields use const char * + size_t pairs rather than NUL-terminated strings. This avoids mutating the internal copy and supports efficient substring access.

  3. 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.

  4. 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

FunctionSignatureDescription
xUrlParsexErrno xUrlParse(const char *raw, xUrl *url)Parse a URL into components
xUrlFreevoid xUrlFree(xUrl *url)Free internal copy, zero all fields

Query

FunctionSignatureDescription
xUrlPortuint16_t xUrlPort(const xUrl *url)Numeric port (explicit or default by scheme)

xUrl Fields

FieldTypeDescription
scheme / scheme_lenconst char * / size_te.g. "https"
userinfo / userinfo_lenconst char * / size_te.g. "user:pass" (optional)
host / host_lenconst char * / size_te.g. "example.com" or "::1"
port / port_lenconst char * / size_te.g. "8443" (optional)
path / path_lenconst char * / size_te.g. "/ws/chat" (optional)
query / query_lenconst char * / size_te.g. "key=val" (optional)
fragment / fragment_lenconst char * / size_te.g. "section1" (optional)

Note: Optional fields have ptr=NULL, len=0 when absent. The raw_ 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

InputResult
NULL raw or url pointerxErrno_InvalidArg
Missing :// separatorxErrno_InvalidArg
Empty host (e.g. http:///path)xErrno_InvalidArg
Unclosed IPv6 bracketxErrno_InvalidArg
malloc failurexErrno_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

OperationComplexityNotes
xUrlParseO(n)Single pass over the URL string
xUrlPortO(1)Converts port string or returns default
xUrlFreeO(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

  1. 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 via xEventLoopSubmit().

  2. 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.

  3. Linked-List Result — Resolved addresses are returned as a linked list of xDnsAddr nodes, preserving the full getaddrinfo() result (family, socktype, protocol) for each address.

  4. 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.

  5. IP Literal Fast Path — If the hostname is an IPv4 or IPv6 literal, AI_NUMERICHOST is 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

FunctionSignatureDescription
xDnsResolvexDnsQuery xDnsResolve(xEventLoop loop, const char *hostname, const char *service, const struct addrinfo *hints, xDnsCallback callback, void *arg)Start async DNS resolution
xDnsCancelvoid xDnsCancel(xEventLoop loop, xDnsQuery query)Cancel a pending query
xDnsResultFreevoid xDnsResultFree(xDnsResult *result)Free a resolution result

Types

TypeDescription
xDnsQueryOpaque handle to a pending query
xDnsResultResolution result: error + addrs linked list
xDnsAddrSingle resolved address node
xDnsCallbackvoid (*)(xDnsResult *result, void *arg)

xDnsResult Fields

FieldTypeDescription
errorxErrnoxErrno_Ok on success
addrsxDnsAddr *Linked list of addresses, or NULL

xDnsAddr Fields

FieldTypeDescription
addrstruct sockaddr_storageResolved socket address
addrlensocklen_tLength of the address
familyintAF_INET or AF_INET6
socktypeintSOCK_STREAM or SOCK_DGRAM
protocolintIPPROTO_TCP or IPPROTO_UDP
nextxDnsAddr *Next address, or NULL

Parameter Details for xDnsResolve

ParameterRequiredDescription
loopYesEvent loop (must not be NULL)
hostnameYesHostname or IP literal (non-empty)
serviceNoPort string (e.g. "443") or NULL
hintsNoaddrinfo hints; NULL defaults to AF_UNSPEC + SOCK_STREAM
callbackYesCompletion callback (must not be NULL)
argNoUser 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

OperationThread Safety
xDnsResolve()Call from event loop thread only
xDnsCancel()Call from event loop thread only
xDnsResultFree()Call from any thread (result is owned)
xDnsCallbackAlways invoked on event loop thread

Error Handling

ScenarioBehavior
NULL loop, hostname, or callbackReturns NULL (no query created)
Empty hostnameReturns NULL
malloc failureReturns NULL
getaddrinfo() failureCallback receives result->error != xErrno_Ok
Cancelled queryCallback is not invoked; result is freed internally

Best Practices

  • Always call xDnsResultFree() in your callback. The callee owns the result.
  • Check result->error before iterating addrs. On failure, addrs is NULL.
  • 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 NULL hints 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 CodexErrnoMeaning
0 (success)xErrno_OkResolution succeeded
EAI_NONAMExErrno_DnsNotFoundHost not found
EAI_AGAINxErrno_DnsTempFailTemporary failure
EAI_MEMORYxErrno_NoMemoryOut of memory
OtherxErrno_DnsErrorGeneric 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 xSocket with an xTransport, plus convenience Recv/Send/SendIov helpers.
  • xTcpConnect — an async connector that performs DNS → socket → non-blocking connect → optional TLS handshake, delivering a ready-to-use xTcpConn via 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

  1. Resource Wrapper, Not Callback Framework — Unlike xWsCallbacks, we intentionally do not provide on_data / on_close callbacks 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; an on_data callback would still deliver arbitrary fragments, leaving the user to reassemble and parse — no better than calling xTcpConnRecv directly. Instead, users register their own xSocketFunc callback via xSocketSetCallback() and drive I/O with xTcpConnRecv / xTcpConnSend.

  2. Transport Transparency — xTcpConn wraps an xTransport vtable. For plain TCP, read/writev map to read(2)/writev(2). For TLS, they map to SSL_read/SSL_write. The Recv/Send/SendIov helpers hide this detail so users never need to reach into xTransport internals.

  3. Full Async Connector Pipeline — xTcpConnect chains DNS resolution → socket creation → non-blocking connect() → optional TLS handshake into a single async operation with a timeout. Each phase is driven by event loop callbacks.

  4. Ownership Transfer — xTcpConnTakeSocket and xTcpConnTakeTransport allow 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

FunctionSignatureDescription
xTcpConnRecvssize_t xTcpConnRecv(xTcpConn conn, void *buf, size_t len)Read up to len bytes; returns bytes read, 0 on EOF, -1 on error
xTcpConnSendssize_t xTcpConnSend(xTcpConn conn, const char *buf, size_t len)Write len bytes; returns bytes written, -1 on error
xTcpConnSendIovssize_t xTcpConnSendIov(xTcpConn conn, const struct iovec *iov, int iovcnt)Scatter-gather write; returns total bytes written, -1 on error
xTcpConnTransportxTransport *xTcpConnTransport(xTcpConn conn)Get the internal transport vtable
xTcpConnSocketxSocket xTcpConnSocket(xTcpConn conn)Get the underlying socket handle
xTcpConnTakeSocketxSocket xTcpConnTakeSocket(xTcpConn conn)Extract socket ownership (conn no longer owns it)
xTcpConnTakeTransportxTransport xTcpConnTakeTransport(xTcpConn conn)Extract transport ownership (conn no longer owns it)
xTcpConnReaderxReader xTcpConnReader(xTcpConn conn)Get an xReader adapter bound to the connection's transport (see io.h)
xTcpConnWriterxWriter xTcpConnWriter(xTcpConn conn)Get an xWriter adapter bound to the connection's transport (see io.h)
xTcpConnClosevoid xTcpConnClose(xTcpConn conn)Close connection and free all resources

xTcpConnect — Async Connector

FunctionSignatureDescription
xTcpConnectxErrno xTcpConnect(const char *host, uint16_t port, const xTcpConnectConf *conf, xTcpConnectFunc callback, void *arg)Initiate async TCP connection

xTcpConnectConf Fields

FieldTypeDefaultDescription
tls_ctxxTlsCtxNULLPre-created shared TLS context (preferred); NULL for plain TCP or auto-create from tls
tlsconst xTlsConf *NULLTLS config for auto-created ctx; ignored when tls_ctx is set; NULL for plain TCP
timeout_msint10000Connect timeout in milliseconds
nodelayint0Set TCP_NODELAY if non-zero
keepaliveint0Set 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

FunctionSignatureDescription
xTcpListenerCreatexTcpListener xTcpListenerCreate(const char *host, uint16_t port, const xTcpListenerConf *conf, xTcpListenerFunc callback, void *arg)Create and start a TCP listener
xTcpListenerDestroyvoid xTcpListenerDestroy(xTcpListener listener)Stop listening and free resources

xTcpListenerConf Fields

FieldTypeDefaultDescription
tls_ctxxTlsCtxNULLTLS context from xTlsCtxCreate(); NULL for plain TCP
backlogint128listen() backlog
reuseportint0Set 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

OperationThread 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 callbackAlways invoked on event loop thread
xTcpListenerFunc callbackAlways invoked on event loop thread

Error Handling

ScenarioBehavior
NULL loop, host, or callback in xTcpConnectReturns xErrno_InvalidArg
DNS resolution failureCallback receives xErrno_DnsError or xErrno_DnsNotFound
connect() failureCallback receives xErrno_SysError
TLS handshake failureCallback receives xErrno_SysError
Connect timeoutCallback receives xErrno_Timeout
xTcpListenerCreate bind/listen failureReturns NULL
xTcpConnRecv/Send on NULL connReturns -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 xSocketFunc on the connection's socket via xSocketSetCallback() to receive read/write events, then use xTcpConnRecv / xTcpConnSend inside the callback.
  • Use xTcpConnSendIov for multi-buffer writes (e.g. header + body) to avoid copying into a single buffer.
  • Set nodelay = 1 in xTcpConnectConf for latency-sensitive protocols (HTTP, WebSocket).
  • Use xTcpConnTakeSocket / xTcpConnTakeTransport when 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

  1. Backend-Agnostic — The config struct contains only file paths and flags. It works identically whether the TLS backend is OpenSSL or mbedTLS.

  2. Zero-Initialize for Defaults — A zero-initialized xTlsConf uses the system CA bundle with full peer and host verification enabled. This is the secure default for both client and server.

  3. Unified Client/Server — A single xTlsConf struct serves both roles. Client-only fields (key_password) and server-only fields (alpn) are simply left as NULL / zero when unused.

  4. 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.

FieldTypeDefaultDescription
certconst char *NULL (none)Path to PEM certificate file
keyconst char *NULL (none)Path to PEM private key file
caconst char *NULL (system CA)Path to CA certificate file
key_passwordconst char *NULL (none)Private key password (client-side)
alpnconst char **NULL (none)NULL-terminated ALPN protocol list (server-side)
skip_verifyint0 (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, cert and key are required. For client-side use, only ca (or defaults) is needed.
  • Returns a TLS context handle, or NULL on 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, cert and key must not be NULL).
  • Returns 0 on success, -1 on 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 in tls.h and implemented in the TLS backend files (transport_openssl.c, transport_mbedtls.c). The TCP listener uses xTlsCtx via xTcpListenerConf.tls_ctx, and the TCP connector uses it via xTcpConnectConf.tls_ctx.
  • xhttp — The HTTP server calls xTlsCtxCreate() internally when xHttpServerListenTls() is invoked, automatically setting ALPN to {"h2", "http/1.1"}. The HTTP client uses libcurl for TLS management and consumes xTlsConf directly. The WebSocket client supports both xTlsConf (auto-creates a context) and a pre-created xTlsCtx (shared across connections) via xWsConnectConf.tls_ctx. See the TLS Deployment Guide for end-to-end examples.

Security Notes

  • Never use skip_verify = 1 in production. It disables all certificate validation.
  • Keep private keys secure. Use restrictive file permissions (chmod 600).
  • For mTLS, set ca to the signing CA on the server side. Zero-initialized skip_verify means verification is enabled by default.
  • The config struct does not copy strings. The caller must ensure that file path strings remain valid until xHttpClientCreate() or xHttpServerListenTls() 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

  1. 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).

  2. 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.

  3. Event Loop Integration — The logger is bound to an xEventLoop and uses its timer and I/O facilities. This means no dedicated logging thread — the event loop thread handles both I/O and log flushing.

  4. Thread-Local Context — xLoggerEnter() sets the current thread's logger, enabling the XLOG_*() macros and bridging xbase's internal xLog() 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

FileDescriptionDoc
logger.hAsync logger API, macros, and configurationlogger.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 xEventLoop for timer-driven and pipe-driven flush.
  • xbase/mpsc.h — Uses the lock-free MPSC queue to pass log entries from producer threads to the event loop thread.
  • xbase/log.h — xLoggerEnter() bridges xbase's internal xLog() calls to the async logger via the thread-local callback mechanism.
  • xbase/atomic.h — Uses atomic operations for 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

  1. 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.

  2. 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.
  3. Lock-Free Entry Pool — A global Treiber stack freelist recycles log entry structs across all threads, avoiding malloc/free on the hot path.

  4. 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.

  5. xbase Bridge — xLoggerEnter() registers a callback with xbase's xLogSetCallback(), 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

TypeDescription
xLoggerOpaque handle to an async logger
xLogLevelEnum: Debug, Info, Warn, Error, Fatal
xLogModeEnum: Timer, Notify, Mixed
xLoggerConfConfiguration struct for creating a logger

xLoggerConf Fields

FieldTypeDefaultDescription
loopxEventLoop(required)Event loop for timer/pipe callbacks
pathconst char *NULL (stderr)Log file path
modexLogModeTimerOperating mode
levelxLogLevelInfoMinimum log level
max_sizesize_t0 (no rotation)Max file size before rotation
max_filesint0 (no rotation)Total files to keep (including current)
flush_interval_msuint64_t100Timer/Mixed flush interval

Functions

FunctionSignatureDescriptionThread Safety
xLoggerCreatexLogger xLoggerCreate(xLoggerConf conf)Create a logger.Not thread-safe
xLoggerDestroyvoid xLoggerDestroy(xLogger logger)Flush remaining entries and destroy.Not thread-safe
xLoggerLogvoid xLoggerLog(xLogger logger, xLogLevel level, const char *fmt, ...)Write a log entry. Fatal is synchronous + abort.Thread-safe
xLoggerFlushvoid xLoggerFlush(xLogger logger)Synchronously flush all pending entries.Thread-safe
xLoggerEntervoid xLoggerEnter(xLogger logger)Set as thread-local logger + bridge xbase log.Thread-local
xLoggerLeavevoid xLoggerLeave(void)Clear thread-local logger.Thread-local
xLoggerCurrentxLogger xLoggerCurrent(void)Get current thread's logger.Thread-local

Convenience Macros

Using thread-local logger (set via xLoggerEnter):

MacroExpands 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

  1. Application Logging — Primary use case: structured, async logging for server applications with file rotation and level filtering.

  2. libx Internal Error Capture — Via xLoggerEnter(), all libx internal errors (from xLog()) are automatically routed through the async logger.

  3. Debug Logging — Use xLogMode_Notify during development for immediate log output without timer delay.

Best Practices

  • Call xLoggerEnter() on every thread that uses XLOG_*() 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 calls abort(). Don't rely on async delivery for fatal messages.

Comparison with Other Libraries

Featurexlog logger.hspdlogzloglog4c
LanguageC99C++11CC
Async ModelMPSC queue + event loopDedicated thread + queueDedicated threadSynchronous
ModesTimer / Notify / MixedAsync (thread pool)Async (thread)Sync only
Lock-FreeYes (MPSC + Treiber stack)Yes (MPMC queue)No (mutex)No (mutex)
Event LoopIntegrated (xEventLoop)None (own thread)None (own thread)None
File RotationSize-based (cascade rename)Size-basedSize/time-basedSize-based
Formatprintf-stylefmt-style / printfprintf-styleprintf-style
Thread-Local ContextYes (xLoggerEnter)NoYes (MDC)Yes (NDC)
Fatal HandlingSync write + abortFlush + abortConfigurableConfigurable

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
ModeFlush TriggerLatencyOverheadBest For
TimerPeriodic timer (default 100ms)Up to flush_interval_msLowest (no per-message syscall)High-throughput logging
NotifyPipe write per message~ImmediateHighest (1 write() per message)Low-latency debugging
MixedTimer + pipe for Error/FatalLow for errors, batched for infoModerateProduction 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, call free() 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:

  1. Delete path.{max_files-1} (oldest)
  2. Cascade rename: path.{i-1} → path.{i} for i = max_files-1 down to 2
  3. Rename path → path.1
  4. Reopen path in 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:

ModeAPIUse Case
DOMxJsonParse / xJsonParseCopyFull in-memory tree, query and mutate, serialize back
SAXxJsonSaxParseLarge documents, callback-driven, no tree overhead

Design Philosophy

  1. Dual Memory Model — Parse trees are arena-backed with O(1) xJsonFree(). Manually constructed trees use per-node malloc with recursive free. Ownership tracking via XJSON_FLAG_OWNED prevents double-free.

  2. Zero-Copy by Default — xJsonParse strings point into the input buffer. Use xJsonParseCopy for safe copy into the arena when the input buffer must be freed.

  3. Ownership Transfer — Set/Append/Insert operations take ownership of the value node. Replacing an existing value frees the old one automatically.

  4. 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

FunctionDescription
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

FunctionReturnUB 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_tXJSON_INT
xJsonDouble(node)doubleXJSON_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().

FunctionCreates
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

FunctionDescription
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

FunctionDescription
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

FunctionOutput
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:

ResultMeaning
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. Use xJsonSaxParse for 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

ConstantValueJSON Type
XJSON_NULL0x00null
XJSON_BOOL0x01true / false
XJSON_INT0x02integer
XJSON_DOUBLE0x03floating-point
XJSON_STRING0x04string
XJSON_ARRAY0x05array
XJSON_OBJECT0x06object

Relationship with Other Modules

  • xbase — Uses xArena for memory management in parse trees. Follows XCAPI / XCAPI_LOCAL visibility 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 xHttpProto vtable 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 an xHttpMux and resolved per-request via a pluggable resolver callback. TLS listeners via xHttpServerListenTls.
  • WebSocket support is symmetric: xWsConnect() for clients, xWsUpgrade() (inside an HTTP handler) or xWsServe() (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

TypeSignatureClientServer
xHttpInitFuncint (xHttpCtx *, void *)on_response — fired once after response headerson_request — fired once after request headers
xHttpDataFuncint (const char *, size_t, void *)on_data — response body chunkson_data — request body chunks
xHttpReadFuncsize_t (char *, size_t, void *)on_read — pull request body for upload(not used)
xHttpDoneFuncvoid (xHttpCtx *, void *)on_done — transfer completeon_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

  1. 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.

  2. Streaming First — Bodies flow through callbacks as chunks, never buffered in full by the library. Uploads are pulled via on_read, downloads pushed via on_data. Collect-into-buffer helpers are a few lines of user code (see client.md).

  3. Decoupled Routing (server) — xHttpServerCreate(conf) takes a resolver callback. The built-in xHttpMux + xHttpMuxResolve covers the common case; custom resolvers can dispatch on any criterion (method, host, header).

  4. Symmetric Callback Types — Client and server share xHttpInitFunc, xHttpDataFunc, xHttpDoneFunc. Only xHttpReadFunc is client-only (upload side).

  5. Automatic Resource Management — Request contexts, curl easy handles, and buffers are cleaned up after on_done returns. 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

FileDescriptionDoc
client.hAsync HTTP client (xHttpCtx, xHttpRequestConf, GET/POST/Do, SSE)client.md
server.hAsync HTTP/1.1 & HTTP/2 server (resolver, xHttpMux, xHttpCtx write API)server.md
sse.cSSE stream parser and request handlersse.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 xEventLoop for I/O multiplexing and xEventLoopTimerAfter for curl timeout management and idle-connection timeouts.
  • xbuf — Uses xBuffer for header accumulation and xIOBuffer for 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 xHttpProto vtable in proto_h1.c.
  • nghttp2 — External dependency (server). HTTP/2 frame processing and HPACK, isolated behind the xHttpProto vtable in proto_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

  1. libcurl Multi-Socket Integration — xhttp uses CURLMOPT_SOCKETFUNCTION + CURLMOPT_TIMERFUNCTION so libcurl delegates socket monitoring to xEventLoop. No dedicated threads, no polling.

  2. 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.

  3. Streaming Bodies — There is no body/body_len field on xHttpCtx. Response body chunks arrive via on_data; request body bytes are pulled via on_read. Memory use is flat regardless of transfer size.

  4. One Config Struct, Four Optional Callbacks — xHttpRequestConf carries the URL, method, headers, and the four callbacks. Any callback left NULL is skipped — set on_done only for fire-and-forget with completion notification, or set all four for full streaming.

  5. Vtable-Based Polymorphism — Internally, each request carries a vtable (xHttpReqVtable) with on_done and on_cleanup function 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

TypeDescription
xHttpClientOpaque handle to an HTTP client bound to an event loop
xHttpCtxPer-request context (status, headers, curl error) — no body field
xHttpInitFuncint (*)(xHttpCtx *ctx, void *arg) — on_response, fired once after headers
xHttpDataFuncint (*)(const char *data, size_t len, void *arg) — on_data, per body chunk
xHttpReadFuncsize_t (*)(char *buf, size_t bufsize, void *arg) — on_read, upload pull
xHttpDoneFuncvoid (*)(xHttpCtx *ctx, void *arg) — on_done, completion
xHttpMethodEnum: GET, POST, PUT, DELETE, PATCH, HEAD
xHttpVersionEnum: Default, H1, H2, H2TLS, H2C
xHttpRequestConfPer-request configuration (URL, method, headers, callbacks)
xHttpClientConfClient creation config (TLS, default HTTP version)
xSseEventSSE event delivered to xSseEventFunc
xSseEventFuncint (*)(const xSseEvent *ev, void *arg) — return 0 to continue, non-zero to close
xSseDoneFuncvoid (*)(int curl_code, void *arg) — SSE stream end
xTlsConfTLS 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.

FieldNotes
content_lengthWhen on_read is set: known body size for Content-Length, or 0 for Transfer-Encoding: chunked.
timeout_msFor regular HTTP: total transfer timeout. For SSE: connection-phase timeout only; stalled streams are detected via libcurl's low-speed-time.
on_responseReturns non-zero to abort before any body data is delivered.
on_dataReturns non-zero to abort the transfer.
on_readReturns 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

FunctionSignatureDescription
xHttpClientCreatexHttpClient xHttpClientCreate(const xHttpClientConf *conf)Create a client. conf may be NULL for defaults.
xHttpClientDestroyvoid 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.

FunctionSignatureDescription
xHttpClientGetxErrno xHttpClientGet(xHttpClient client, const xHttpRequestConf *conf, void *arg)Force conf->method = GET, delegate to Do.
xHttpClientPostxErrno xHttpClientPost(xHttpClient client, const xHttpRequestConf *conf, void *arg)Force conf->method = POST, delegate to Do. Body via conf->on_read.
xHttpClientDoxErrno 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

FunctionSignatureDescription
xHttpClientGetSsexErrno xHttpClientGetSse(xHttpClient client, const char *url, xSseEventFunc on_event, xSseDoneFunc on_done, void *arg)Simple GET SSE subscription.
xHttpClientDoSsexErrno 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).

ValueDescription
xHttpVersion_DefaultUse client default (initially HTTP/1.1)
xHttpVersion_H1Force HTTP/1.1
xHttpVersion_H2HTTP/2 with TLS (ALPN), fallback to H1
xHttpVersion_H2TLSHTTP/2 over TLS only, no fallback
xHttpVersion_H2CHTTP/2 cleartext (Prior Knowledge)

TLS Configuration

TLS is configured at client creation time via xHttpClientConf.tls. The xTlsConf fields are deep-copied internally.

xTlsConf FieldDescription
caPath to a CA cert file. When set, system CA bundle is bypassed.
certPath to a client cert (PEM) for mTLS.
keyPath to the client private key (PEM).
key_passwordPassphrase for an encrypted private key.
skip_verifyNon-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

  1. REST API Integration — Async calls to microservices, cloud APIs, or webhooks from an event-driven C application.
  2. Streaming Uploads / Downloads — Large file transfers without buffering the whole payload in memory. Use on_read to pull from a file or generator; use on_data to write chunks to disk as they arrive.
  3. LLM API Calls — xHttpClientDoSse() with POST + JSON body to stream from OpenAI, Anthropic, or any OpenAI-compatible API. See sse.md.
  4. Conditional Fetches — Inspect status and headers in on_response, abort early on 3xx/4xx before any body data is transferred.
  5. 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 the data pointer in on_data are valid only during the callback.
  • Use xHttpClientDo() for full control. Get/Post are thin wrappers that force the method — Do accepts whatever is in conf.
  • Destroy the client before the event loop. xHttpClientDestroy() cancels in-flight requests and invokes their on_done with an error status before resources are freed.
  • Check curl_code first. A curl_code of 0 means the HTTP transfer succeeded; then check status_code for the HTTP-level result. Non-zero curl_code indicates a transport/DNS/TLS failure.
  • Never use skip_verify in production. It disables all certificate validation. Use a proper CA path or system CA bundle instead.
  • For SSE, timeout_ms only 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

Featurexhttp client.hlibcurl easy APIcpp-httplibPython requests
I/O ModelAsync (event loop)BlockingBlockingBlocking
Event LoopxEventLoop integrationNone (or manual multi)NoneNone (asyncio separate)
Streaming Uploadon_read callbackREADFUNCTIONNoNo (stream=...)
Streaming Downloadon_data callbackWRITEFUNCTIONNoiter_content
SSE SupportBuilt-in (GetSse/DoSse)Manual parsingNoNo (needs sseclient)
TLS ConfigxHttpClientConf.tls at creationcurl_easy_setopt (manual)Built-inverify/cert params
Thread ModelSingle-threaded callbacksOne thread per requestOne thread per requestOne thread per request
LanguageC99CC++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:

  1. CURL_POLL_REMOVE — Unregister the fd from the event loop (xEventDel).
  2. 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:

  1. timeout_ms == -1 — Cancel any existing timer.
  2. timeout_ms == 0 — Schedule a 1ms timer (deferred to avoid reentrant curl_multi_socket_action).
  3. timeout_ms > 0 — Schedule via xEventLoopTimerAfter.

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

  1. Single-Threaded Event-Driven I/O — Accept, read, parse, dispatch, and write all happen on the event loop thread, eliminating synchronization overhead.

  2. Protocol-Abstracted Parsing — Request parsing is delegated to a protocol handler behind the xHttpProto vtable. 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.

  3. Decoupled Resolver — xHttpServerConf.resolve is a function pointer. The server does not own route state — your resolver returns a xHttpRouteInfo * (which may come from a xHttpMux, a static table, or computed on the fly). This makes routing trivially extensible.

  4. Streaming Request Body — Request body chunks arrive via on_data; the request is complete when on_done fires. There is no body/body_len field on xHttpCtx and no max_body_size limit — the application decides how much to buffer.

  5. Response via xHttpCtx* functions — xHttpCtxSetStatus, xHttpCtxSetHeader, xHttpCtxSend (one-shot), xHttpCtxWrite (streaming), xHttpCtxYield / xHttpCtxResume (async response), xHttpCtxParam (path parameters). No separate response writer handle.

  6. 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.

  7. Pluggable TLS — TLS via xHttpServerListenTls() with xTlsConf. ALPN negotiation selects HTTP/1.1 or HTTP/2 over TLS. mTLS is supported when ca is 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

TypeDescription
xHttpServerOpaque handle to an HTTP server bound to an event loop
xHttpMuxOpaque handle to the built-in pattern router
xHttpCtxPer-request context (method, url, headers, internal response state)
xHttpInitFuncint (*)(xHttpCtx *ctx, void *arg) — on_request, fired after headers
xHttpDataFuncint (*)(const char *data, size_t len, void *arg) — on_data, request body chunks
xHttpDoneFuncvoid (*)(xHttpCtx *ctx, void *arg) — on_done, request complete
xHttpResolveFuncconst xHttpRouteInfo *(*)(void *router, xHttpCtx *ctx) — maps a request to a route
xHttpRouteInfoStruct returned by the resolver: on_request / on_data / on_done / arg
xHttpServerConfServer creation config: resolve, router, idle_timeout_ms, max_header_size
xHttpRouteConfRoute registration for xHttpMux: pattern + the three callbacks + arg
xTlsConfTLS 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

FunctionSignatureDescription
xHttpServerCreatexHttpServer xHttpServerCreate(const xHttpServerConf *conf)Create a server. conf may be NULL for defaults (no resolver → 404).
xHttpServerListenxErrno xHttpServerListen(xHttpServer server, const char *host, uint16_t port)Start listening for HTTP (cleartext).
xHttpServerListenTlsxErrno 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.
xHttpServerDestroyvoid xHttpServerDestroy(xHttpServer server)Destroy server, close all connections. Safe to call with NULL.

Configuration

FunctionDescriptionDefault
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)

FunctionSignatureDescription
xHttpMuxCreatexHttpMux xHttpMuxCreate(void)Create a new multiplexer.
xHttpMuxDestroyvoid xHttpMuxDestroy(xHttpMux mux)Destroy the mux and free all registered routes.
xHttpMuxHandlexErrno xHttpMuxHandle(xHttpMux mux, const xHttpRouteConf *conf)Register a route.
xHttpMuxResolveconst 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

FunctionSignatureDescription
xHttpCtxSetStatusvoid xHttpCtxSetStatus(xHttpCtx *ctx, int code)Set HTTP status (default 200).
xHttpCtxSetHeaderxErrno xHttpCtxSetHeader(xHttpCtx *ctx, const char *key, const char *value)Add a response header. Call before Send or the first Write.
xHttpCtxSendxErrno 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.
xHttpCtxWritexErrno 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.
xHttpCtxYieldvoid 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.
xHttpCtxResumevoid xHttpCtxResume(xHttpCtx *ctx)Resume a yielded connection after sending the response.
xHttpCtxParamconst 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 FieldDescription
certPath to PEM certificate file (required for ListenTls).
keyPath to PEM private key file (required).
caPath to CA cert file for client verification (optional — enables mTLS).
skip_verifyNon-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_done is called, the request body has been fully delivered via on_data. Allocate the per-request state in on_request (or lazily in on_data) and free it in on_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() or xHttpCtxWrite(). If on_done returns without writing, a default 200 OK with empty body is sent automatically — but it's better to be explicit.
  • Don't mix Send and Write. Send is for one-shot responses (sets Content-Length); Write is for streaming (no Content-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 with xHttpCtxResume() when done.
  • Configure limits before listening. xHttpServerSetMaxHeaderSize() and the idle_timeout_ms / max_header_size fields of xHttpServerConf must be set before Listen / ListenTls.
  • Register routes before listening. Add all xHttpMuxHandle() calls before xHttpServerListen() — the mux is read on every request.
  • Free per-request state in on_done. Memory allocated in on_request or on_data for a single request should be freed in on_done.
  • Copy data you need to keep. xHttpCtx pointers (method, url, headers) and the data pointer in on_data are valid only during the callback.
  • Destroy server before event loop. xHttpServerDestroy() closes all connections and frees all resources.

Comparison with Other Libraries

Featurexhttp server.hlibuv + http-parserlibmicrohttpdGo net/httpNode.js http
I/O ModelAsync (event loop)Async (event loop)Threaded / selectGoroutinesAsync (event loop)
HTTP Parserllhttp (H1) + nghttp2 (H2)http-parser / llhttpInternalInternalllhttp
Streaming Request Bodyon_data callbackManualManualBody readerdata event
Streaming ResponsexHttpCtxWriteManualManualFlusherwrite
RoutingPluggable resolver + xHttpMuxNoneNoneServeMuxNone
Keep-AliveAutomaticManualAutomaticAutomaticAutomatic
HTTP/2h2c + h2 (ALPN)ManualNoYesNo
TLS/HTTPSBuilt-in (ListenTls, mTLS)ManualBuilt-inBuilt-inBuilt-in
LanguageC99CCGoJavaScript

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

  1. Path match — Segment-by-segment comparison. Static segments require exact match; :param segments match any non-empty string and capture the value.
  2. Method match — Case-insensitive. A pattern without a method prefix (e.g. "/any") matches any HTTP method.
  3. Fallback — Path matches but no method matches → 405 Method Not Allowed. No path matches → 404 Not Found. Resolver returns NULL → 404.
  4. Parameter access — Inside a handler, call xHttpCtxParam(ctx, "id", &len) to retrieve the captured value.

Response Serialization

When xHttpCtxSend() is called:

  1. Status line (HTTP/1.1 <code> <reason>\r\n) is written to the xIOBuffer.
  2. Content-Length header is added automatically.
  3. Connection: keep-alive or Connection: close is added based on the parser's determination.
  4. User-set headers are appended.
  5. Header section is terminated with \r\n.
  6. Body is appended.
  7. conn_try_flush() attempts an immediate writev(). If EAGAIN, 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_complete to 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:

  1. 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.
  2. Otherwise, xHttpProtoH1Init() is called.
  3. 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

FeatureHTTP/1.1 (proto_h1)HTTP/2 (proto_h2)
Parserllhttp (byte stream → request)nghttp2 (byte stream → frame → stream)
MultiplexingNone (pipelining at best)Native, multiple concurrent streams
HeadersPlain text Key: ValueHPACK compressed pseudo-headers + regular headers
Keep-aliveConnection: keep-alive headerAlways persistent (multiplexed)
Response framingRaw HTTP/1.1 status line + headers + bodynghttp2_submit_response() → HEADERS + DATA frames
Flow controlNoneBuilt-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 xEventLoop for I/O multiplexing, xSocket for non-blocking socket management, and socket timeouts for idle connection detection.
  • xbuf — Uses xBuffer for request parsing accumulation (URL, headers) and xIOBuffer for read/write buffering with scatter-gather I/O.
  • xnet — Provides xTlsConf and the TLS backend abstraction used by xHttpServerListenTls.
  • llhttp — External dependency. Incremental HTTP/1.1 parsing via callbacks, isolated behind the xHttpProto vtable in proto_h1.c.
  • nghttp2 — External dependency. HTTP/2 frame processing, HPACK header compression, and stream management, isolated behind the xHttpProto vtable in proto_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 for xHttpServerListenTls().

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

  1. Handler-Initiated Upgrade — WebSocket connections start as regular HTTP requests. The user calls xWsUpgrade(ctx, ...) inside a route callback (on_request or on_done) to perform the upgrade. This keeps routing unified: WebSocket endpoints are just xHttpMux routes.

  2. 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.

  3. 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.

  4. Connection Hijacking — On successful upgrade, the HTTP connection's socket and transport layer are transferred to a new xWsConn object. The HTTP connection is destroyed; the WebSocket connection takes full ownership of the file descriptor.

  5. Pluggable Crypto Backend — The handshake requires SHA-1 and Base64 for Sec-WebSocket-Accept computation. 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

TypeDescription
xWsConnOpaque WebSocket connection handle
xWsOpcodeMessage type: Text (0x1), Binary (0x2)
xWsCallbacksStruct of 3 optional callback pointers
xWsConnectConfClient-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

FunctionDescription
xWsServeOne-call WebSocket-only server
xWsUpgradeUpgrade HTTP → WebSocket (call from a route callback)
xWsSendSend a text or binary message
xWsCloseInitiate 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

CodeConstantMeaning
1000XWS_CLOSE_NORMALNormal closure
1001XWS_CLOSE_GOING_AWAYServer shutting down
1002XWS_CLOSE_PROTOCOL_ERRProtocol error
1003XWS_CLOSE_UNSUPPORTEDUnsupported data
1005XWS_CLOSE_NO_STATUSNo status received
1006XWS_CLOSE_ABNORMALAbnormal 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) or on_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-liner xWsServe() uses on_done internally.

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 — the xHttpCtx* is no longer valid and you must not call any xHttpCtx* 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 payload pointer in on_message is 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_close for cleanup. Free per-connection resources in on_close, as the xWsConn handle becomes invalid after the callback returns.
  • Idle timeout is set on the server. The WebSocket connection inherits the xHttpServerConf.idle_timeout_ms setting. Adjust it when creating the server if you need longer-lived connections.

Comparison with Other Libraries

Featurexhttp WSlibwebsocketsuWebSockets
IntegrationxEventLoopOwn loopOwn loop
UpgradeIn HTTP route callbackSeparateSeparate
Fragment reassemblyAutomaticAutomaticAutomatic
Ping/PongAutomaticAutomaticAutomatic
Close handshakeRFC 6455RFC 6455RFC 6455
TLSVia xhttpBuilt-inBuilt-in
LanguageC99CC++
Dependenciesxbase onlyOpenSSLNone

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:

OpcodeHandling
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:

  1. First fragment (FIN=0, opcode=Text/Binary) starts accumulation in frag_buf.
  2. Continuation frames (opcode=0x0) append to frag_buf.
  3. 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 to CLOSE_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_close fires 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

FileRole
ws.hPublic API (types, callbacks, functions)
ws.cConnection lifecycle, I/O, frame dispatch
ws_handshake_server.cServer upgrade handshake (RFC 6455 §4.2)
ws_frame.h/cFrame codec (parse + encode)
ws_crypto.hSHA-1 + Base64 interface
ws_crypto_openssl.cOpenSSL backend
ws_crypto_mbedtls.cMbed TLS backend
ws_crypto_builtin.cBuilt-in (no TLS dep)
ws_serve.cxWsServe() convenience wrapper
ws_private.hInternal 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

  1. 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.

  2. Shared Connection Model — Once the handshake completes, a client xWsConn is identical to a server xWsConn. The same xWsSend(), xWsClose(), and callback interfaces apply. Code that operates on xWsConn doesn't need to know which side initiated the connection.

  3. Failure via on_close — If the connection fails at any stage (DNS, TCP, TLS, or HTTP Upgrade), on_close is invoked with an error code. on_open is never called for failed connections. Cleanup always happens in one place.

  4. 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

TypeDescription
xWsConnOpaque WebSocket connection handle (shared with server)
xWsOpcodeMessage type: Text (0x1), Binary (0x2)
xWsCallbacksStruct of 3 optional callback pointers (shared with server)
xWsConnectConfConfiguration 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)            */
};
FieldDescription
urlWebSocket URL. Must start with ws:// or wss://. Required.
tlsTLS configuration for wss:// connections. NULL uses system CA with verification enabled. Ignored for ws://. Ignored when tls_ctx is set.
tls_ctxPre-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).
headersExtra HTTP headers appended to the Upgrade request. Format: "Key: Value\r\nKey2: Value2\r\n". NULL for none.
timeout_msTimeout 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, conn is NULL.

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->url required).
  • 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 returns xErrno_InvalidArg for obviously bad parameters (NULL pointers, unsupported URL scheme). Network errors are reported asynchronously via on_close.
  • Handle conn == NULL in on_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 payload pointer in on_message is 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_ms for high-latency networks.
  • Never use skip_verify in production. It disables all certificate validation. Use a proper CA path or system CA bundle instead.

Comparison with Other Libraries

Featurexhttp WS Clientlibwebsocketswslaycivetweb
I/O ModelAsync (event loop)Async (own loop)Sync (user drives)Threaded
Event LoopxEventLoopOwn loopNonepthreads
DNSAsync (xDnsResolve)Async (built-in)ManualBlocking
TLSVia xnetBuilt-inManualBuilt-in
Client MaskingAutomaticAutomaticAutomaticAutomatic
Connection TimeoutConfigurableConfigurableManualConfigurable
LanguageC99CCC
Dependenciesxbase + xnetOpenSSLNoneNone

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

PhaseWhat Happens
DNSxDnsResolve() resolves the hostname asynchronously. On success, proceeds to TCP.
TCP ConnectCreates an xSocket, calls connect(). Waits for the writable event (EINPROGRESS).
TLS HandshakeFor wss:// URLs only. Initializes the TLS transport and drives the handshake via read/write events.
HTTP Upgrade WriteBuilds the Upgrade request (with random Sec-WebSocket-Key) and flushes it to the server.
HTTP Upgrade ReadReads 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

FileRole
ws.hPublic API (xWsConnect, xWsConnectConf)
ws_connect.cAsync connection state machine
ws_handshake_client.h/cBuild Upgrade request, validate 101 response
ws_crypto.hSHA-1 + Base64 for Sec-WebSocket-Accept
transport_tls_client.hTLS client transport init (shared xTlsCtx → per-connection SSL)
transport_tls_client_openssl.cOpenSSL TLS client transport implementation
transport_tls_client_mbedtls.cmbedTLS 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

  1. W3C Spec Compliance — Field parsing (event, data, id, retry), comment handling, multi-line data joining with \n, and default event type "message".

  2. Streaming Parse — Data is parsed incrementally as it arrives from libcurl's write callback. Complete lines are processed immediately; incomplete lines are buffered.

  3. Shared Infrastructure — SSE requests reuse the same curl_multi handle and event-loop integration as regular HTTP requests. The xHttpReqVtable mechanism lets SSE plug in its own write callback and completion handler.

  4. POST + Request Body via on_read — xHttpClientDoSse() takes a full xHttpRequestConf, so the request body for POST-based SSE (LLM APIs) is streamed via on_read — no body/body_len fields to keep alive. Set content_length for Content-Length, or leave it 0 for chunked.

  5. User-Controlled Cancellation — The xSseEventFunc callback returns an int: 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

TypeDescription
xSseEventSSE event: event (type), data, id, retry
xSseEventFuncint (*)(const xSseEvent *ev, void *arg) — return 0 to continue, non-zero to close
xSseDoneFuncvoid (*)(int curl_code, void *arg) — called when stream ends
xHttpRequestConfPer-request config (used by DoSse) — URL, method, headers, on_read for body

xSseEvent Fields

FieldTypeDescription
eventconst char *Event type. "message" if omitted by server.
dataconst char *Event data. Multi-line data joined by \n.
idconst char *Last event ID, or NULL.
retryintRetry delay in ms, or -1 if not set.

All strings are NUL-terminated and valid only during the callback.

Functions

FunctionSignatureDescription
xHttpClientGetSsexErrno xHttpClientGetSse(xHttpClient client, const char *url, xSseEventFunc on_event, xSseDoneFunc on_done, void *arg)Subscribe to a GET SSE endpoint.
xHttpClientDoSsexErrno 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, and on_done all receive the same arg pointer, so a single struct StreamCtx holding 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

  1. LLM API Integration — Stream responses from OpenAI, Anthropic, Google Gemini, or any OpenAI-compatible API. Use xHttpClientDoSse() with POST + JSON body.
  2. Real-Time Notifications — Subscribe to server push (chat messages, stock prices, IoT sensor data) via GET SSE endpoints.
  3. 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. GetSse is 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 from on_event to close cleanly.
  • Stream the request body via on_read. Don't try to stuff the body into a body/body_len field — xHttpRequestConf has none. Use on_read + content_length for a known-size body, or on_read + content_length = 0 for chunked.
  • Set appropriate timeouts. timeout_ms covers 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. xSseEvent pointers are valid only during the callback.

Comparison with Other Libraries

Featurexhttp SSEeventsource (JS)sseclient-pylibcurl (manual)
Spec ComplianceW3C SSEW3C SSEW3C SSEManual parsing
IntegrationxEventLoop (async)Browser event loopBlocking iteratorManual
POST SupportYes (DoSse)No (GET only)No (GET only)Manual
Streaming Request Bodyon_read callbackN/AN/AREADFUNCTION
CancellationCallback return valueclose()Break loopcurl_easy_pause
Multi-line DataAuto-joined with \nAuto-joinedAuto-joinedManual
LanguageC99JavaScriptPythonC

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 FormatFieldValue
:comment(ignored)—
event:typeevent_type"type"
data:payloaddata"payload" (accumulated with \n)
id:123id"123" (persists across events)
retry:5000retry5000 (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's on_done callback.
  • 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 (openssl command).
  • TLS backend compiled — libx must be built with X_TLS_BACKEND=openssl (or mbedtls). Without a TLS backend, xHttpServerListenTls() returns xErrno_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 certificate
  • server-key.pem — Unencrypted private key

Note: Self-signed certificates are not trusted by default. Clients must either set skip_verify = 1 or provide the certificate as a CA via ca.

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:

FileDescription
ca.pemCA certificate (trusted by both sides)
ca-key.pemCA private key (keep secure, not deployed)
server.pemServer certificate (signed by CA)
server-key.pemServer private key
client.pemClient certificate (signed by CA)
client-key.pemClient 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

ValueBehavior
0 (default)Peer verification enabled. Server verifies client cert (if ca is set); client verifies server cert.
non-zeroAll 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 h2 and 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

SymptomCauseFix
xErrno_NotSupported from ListenTlsNo TLS backend compiledRebuild with X_TLS_BACKEND=openssl
Client gets curl_code != 0, status_code == 0TLS handshake failedCheck cert paths, CA trust, and skip_verify settings
Self-signed cert rejectedClient verifies against system CA bundleSet ca to the self-signed cert, or use skip_verify = 1 for dev
mTLS handshake failsClient didn't provide cert, or cert not signed by server's caEnsure client cert is signed by the same CA specified in server's ca
"wrong CA path" errorca points to non-existent fileVerify the file path exists and is readable
Connection works with skip_verify but not withoutServer cert CN doesn't match hostname, or CA not trustedUse ca pointing to the signing CA, ensure CN matches the hostname

Security Best Practices

  1. Never use skip_verify in production. It disables all certificate validation, making the connection vulnerable to MITM attacks.
  2. Keep private keys secure. ca-key.pem, server-key.pem, and client-key.pem should have restricted file permissions (chmod 600).
  3. Use short-lived certificates. Set reasonable expiry (-days) and rotate certificates before they expire.
  4. For mTLS, set ca on the server side. Verification is enabled by default (skip_verify = 0), so the server will require a valid client certificate when ca is set.
  5. Don't deploy the CA private key. Only ca.pem (the public certificate) needs to be distributed. Keep ca-key.pem offline or in a secure vault.
  6. 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

ItemDescription
xTlsConfStruct: cert, key, ca, key_password, skip_verify
xHttpServerConfStruct: 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

ItemDescription
xTlsConfStruct: ca, cert, key, key_password, skip_verify
xHttpClientConfStruct: tls (pointer to xTlsConf), http_version
xHttpClientCreate(&conf)Create client with TLS config
xHttpRequestConfPer-request config: url, method, headers, on_read, on_data, on_done

WebSocket Client Side

ItemDescription
xTlsConfStruct: ca, cert, key, key_password, skip_verify
xTlsCtxOpaque shared TLS context from xTlsCtxCreate()
xWsConnectConfStruct: 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_AAAA sends 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

  1. 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.

  2. 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.

  3. Double-Packed — Multi-type queries (A | AAAA) are packed into a single xDnsClientDo() call. The client sends one UDP packet per type and merges results, invoking the callback once.

  4. 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.

  5. 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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

TypeDescription
xDnsTypeBitmask enum: xDnsType_A (1<<0), xDnsType_AAAA (1<<1), xDnsType_CNAME (1<<2)
xDnsRecordSingly-linked list node: qtype, ttl, name, rdata, rdlength, next
xDnsClientConfClient config: nameservers[8], timeout_ms, retries, enable_cache
xDnsServerConfServer config: forwarder, filter, filter_arg, cache_enabled
xDnsCallbackvoid (*)(xErrno err, const xDnsRecord *records, void *arg)
xDnsFilterFuncint (*)(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 xDnsClient multiplexes all queries over one UDP socket.
  • Copy data in callbacks — xDnsRecord pointers are valid only during the callback.
  • Create forwarder before server — The xDnsClient passed via xDnsServerConf.forwarder must outlive the server.
  • Free zones separately — xDnsServerDestroy() does not free zones.
  • Handle partial success — A | AAAA may 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 xEventLoop for async I/O, xSocket/xSocketSendTo/xSocketRecvFrom for UDP, xTimer for query timeouts, and xMap for 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

  1. 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.

  2. 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).

  3. Cache-First — When caching is enabled, a cache hit invokes the callback immediately via a zero-timer, avoiding network I/O entirely.

  4. Retry with Rotation — On timeout, the next nameserver in the configured list is tried. Each nameserver gets up to retries attempts before moving on.

  5. Merge, Don't Serialize — xDnsType_A | xDnsType_AAAA sends 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

FunctionSignatureDescription
xDnsClientCreatexDnsClient xDnsClientCreate(const xDnsClientConf *conf)Create a client bound to the current event loop. conf may be NULL for defaults.
xDnsClientDestroyvoid xDnsClientDestroy(xDnsClient client)Destroy client. In-flight queries are cancelled; callbacks NOT invoked. Safe with NULL.
xDnsClientDoxErrno 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_Ok on success (including partial results). xErrno_Timeout on total timeout. xErrno_DnsNotFound on NXDOMAIN.
  • records — Linked list of xDnsRecord, or NULL on error. Valid only during the callback.
  • arg — User argument from xDnsClientDo().

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 — xDnsRecord pointers are library-owned and freed after the callback returns.
  • Check error codes — xErrno_Ok means success (possibly with 0 records for NODATA). xErrno_Timeout means all nameservers were tried and none responded.
  • Handle partial success — A | AAAA may partially succeed. Check each record's qtype individually.
  • Initiate queries before running the loop — xDnsClientDo() must be called from the event loop thread before xEventLoopRun().

Comparison with Other Libraries

Featurexdns clientgetaddrinfo + thread poolc-ares
Async ModelEvent-loop nativeThread-pool wrapperEvent-loop native
No ThreadsYesNoYes
Protocol-NativeYes (builds DNS packets)No (OS resolver)Yes
Bitmask QueriesYes (A | AAAA)NoNo (separate calls)
TTL CacheBuilt-inVaries by OSVia ares_library_init
EDNS0RFC 6891 (4096-byte UDP)OS-dependentYes
Dependenciesxbase onlyPOSIX threadslibcares
LanguageC99CC

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

  1. 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.

  2. Composable Filtering — The filter callback runs before zone lookup and forwarding, allowing ad-blocking, access control, or custom DNS logic without modifying the core.

  3. Stateless Responses — Each query is self-contained. The server does not maintain connection state — it parses, processes, and responds in a single callback.

  4. Cache Sharing — When cache_enabled is 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

FunctionSignatureDescription
xDnsServerCreatexDnsServer xDnsServerCreate(const xDnsServerConf *conf)Create server. conf may be NULL for authoritative-only with no filter.
xDnsServerDestroyvoid xDnsServerDestroy(xDnsServer server)Destroy server and close listener. Zones are NOT freed. Safe with NULL.
xDnsServerListenxErrno xDnsServerListen(xDnsServer server, const char *host, uint16_t port)Start listening on a UDP port. host may be NULL for 0.0.0.0.
xDnsServerPortuint16_t xDnsServerPort(xDnsServer server)Return the actual bound port (useful when port was 0).
xDnsServerAddZonexErrno xDnsServerAddZone(xDnsServer server, xDnsZone zone)Attach a zone. Zones checked in registration order.

Zone

FunctionSignatureDescription
xDnsZoneCreatexDnsZone xDnsZoneCreate(void)Create an empty zone.
xDnsZoneDestroyvoid xDnsZoneDestroy(xDnsZone zone)Destroy zone and free all records. Safe with NULL.
xDnsZoneAddxErrno 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 xDnsClient passed via xDnsServerConf.forwarder must outlive the server.
  • Free zones separately — xDnsServerDestroy() does not free zones. Call xDnsZoneDestroy() 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 both name and rdata. 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

  1. 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.

  2. 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.

  3. 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.

  4. 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 xPeerConnection for the full WebRTC experience.

  5. 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

HeaderComponentDescriptionDoc
peer_connection.hxPeerConnectionWebRTC PeerConnection — orchestrates ICE + DTLS + SCTP + DataChannelpc.md
datachannel.hxDataChannel / xDataChannelMgrWebRTC DataChannel (DCEP, RFC 8832) over SCTP streamspc.md
dtls_transport.hxDtlsTransportDTLS 1.2 transport with backend-agnostic design (OpenSSL / mbedTLS)pc.md
sctp_transport.hxSctpTransportSCTP over DTLS via usrsctp for WebRTC DataChannelpc.md
ice_agent.hxIceAgentFull ICE agent — gathering, checks, nomination, data send/recvice.md
ice_candidate.hxIceCandidateCandidate representation and priority calculation (RFC 8445 §5.1.2.1)—
ice_pair.hxIcePairCandidate pair priority and sorting (RFC 8445 §6.1.2.3)—
sdp.hxIceSdpSDP offer/answer encoding and decoding (RFC 4566)—
stun_msg.hxStunMsgSTUN message header encoding/decoding (RFC 5389)—
stun_attr.hxStunAttrWriter / xStunAttrIterSTUN attribute encoding/decoding with integrity and fingerprint—
stun_txn.hxStunTxnMgrSTUN transaction manager with exponential-backoff retransmission—
turn_client.hxTurnClientTURN allocation, permissions, channel bindings, and relay data (RFC 5766)—
turn_channel.hxTurnChannelTURN ChannelData framing (RFC 5766 §11)—
ice_crypto.hxIceHmacSHA1 / xIceCrc32Built-in HMAC-SHA1, SHA-1, MD5, CRC-32—

Quick Start

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 xEventLoop for I/O multiplexing, xSocket for non-blocking UDP socket management, and timers for ICE connectivity checks and DTLS retransmission.
  • xbuf — Uses xBuffer for SDP string assembly and xIOBuffer for 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 xPeerConnection API 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).
#include <x/p2p/ice_agent.h>

States

The ICE agent progresses through the following states:

New → Gathering → Checking → Connected → Completed
                                ↘         ↗
                                 Failed
                                    ↓
                                  Closed
StateValueDescription
xIceState_New0Initial state, no activity yet
xIceState_Gathering1Gathering local candidates (host / srflx / relay)
xIceState_Checking2Performing connectivity checks on candidate pairs
xIceState_Connected3At least one valid pair found
xIceState_Completed4All checks done, nominated pair selected
xIceState_Failed5All checks failed, no valid pair
xIceState_Closed6Agent has been shut down

Roles

RoleValueDescription
xIceRole_Controlling0Initiates nomination (sends USE-CANDIDATE)
xIceRole_Controlled1Accepts 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

FunctionDescription
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

FunctionDescription
xIceAgentGather(agent)Start candidate gathering. Enumerates interfaces, sends STUN/TURN requests. Candidates reported via on_candidate.

SDP Exchange

FunctionDescription
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

FunctionDescription
xIceAgentSend(agent, data, len)Send data through the nominated pair. Only valid in Connected or Completed state.

Candidate Types

TypePriority PrefDescription
host126Direct local interface address
srflx100Server-reflexive (public address from STUN)
prflx110Peer-reflexive (discovered during checks)
relay0TURN 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

FlagDescription
-s host:portSTUN server address (default: stun.l.google.com:19302). Pass -s "" to disable.
-f typeFilter candidates by type (host, srflx, relay). Default: keep all.
-6Enable IPv6 candidate gathering (disabled by default).

Protocol Constants

ConstantValueDescription
XICE_GATHER_TIMEOUT_MS5000Candidate gathering timeout
XICE_CHECK_TIMEOUT_MS10000Connectivity check timeout
XICE_CHECK_PACING_MS50Check pacing interval
XICE_CONSENT_INTERVAL_MS15000Consent freshness interval (RFC 7675)
XICE_MAX_CANDIDATES32Max candidates per agent
XICE_MAX_PAIRS128Max candidate pairs
XSTUN_INITIAL_RTO_MS500Initial STUN retransmission timeout
XSTUN_MAX_RETRANSMITS7Max 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
StateValueDescription
xPeerConnectionState_New0Initial state, no activity yet.
xPeerConnectionState_Connecting1ICE/DTLS/SCTP handshake in progress.
xPeerConnectionState_Connected2DataChannel ready for use.
xPeerConnectionState_Disconnected3Connectivity lost (may recover).
xPeerConnectionState_Failed4Unrecoverable failure.
xPeerConnectionState_Closed5Explicitly 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

FunctionDescription
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

FunctionDescription
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

FunctionDescription
xPeerConnectionCreateDataChannel(pc, conf)Create a new DataChannel. The channel opens once the SCTP association is established. Returns NULL on failure.

Accessors

FunctionDescription
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

FunctionDescription
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

StateValueDescription
xDataChannelState_Connecting0OPEN sent, waiting for ACK.
xDataChannelState_Open1Channel is open for data.
xDataChannelState_Closing2Close initiated.
xDataChannelState_Closed3Channel 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:

BackendCMake OptionDescription
OpenSSL-DX_TLS_BACKEND=openssl (default)Uses OpenSSL for DTLS 1.2 handshake and encryption.
mbedTLS-DX_TLS_BACKEND=mbedtlsUses 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

OperationThread 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 callbacksAlways invoked on event loop thread

Error Handling

ScenarioBehavior
NULL loop or conf in CreateReturns NULL
ICE gathering failureon_state_change reports Failed
DTLS handshake failureon_state_change reports Failed
SCTP association failureon_state_change reports Failed
Invalid remote SDPSetRemoteDescription returns error xErrno
Send on closed DataChannelReturns xErrno error
xPeerConnectionDestroy(NULL)No-op (safe)

Best Practices

  • Exchange SDP after gathering completes — Wait for the on_ice_candidate(NULL) signal before calling CreateOffer / CreateAnswer to include all candidates in the SDP. Alternatively, use Trickle ICE with AddIceCandidate for faster setup.
  • Set callbacks in conf before Create — All callbacks must be configured in xPeerConnectionConf before calling xPeerConnectionCreate. They cannot be changed after creation.
  • Use per-channel callbacks for complex apps — Set on_open / on_message / on_close in xDataChannelConf to override the PeerConnection-level defaults for individual channels.
  • Destroy in order — Call xPeerConnectionDestroy which 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

Featurexp2p PeerConnectionlibdatachannelPion (Go)libwebrtc (Google)webtransport-go
LanguageC99C++GoC++Go
I/O ModelAsync (xEventLoop, single-threaded)Async (internal thread pool)GoroutinesMulti-threadedGoroutines
ICEBuilt-in (RFC 8445, full agent)Built-in (libnice / libjuice)Built-inBuilt-inN/A (QUIC)
DTLS BackendPluggable (OpenSSL / mbedTLS)GnuTLS / OpenSSLpion/dtls (pure Go)BoringSSLN/A (QUIC TLS)
SCTPusrsctp (user-space)usrsctppion/sctp (pure Go)usrsctpN/A
DataChannelDCEP (RFC 8832)DCEP (RFC 8832)DCEP (RFC 8832)DCEP (RFC 8832)Datagrams / Streams
Audio/VideoNot supported (data-only)Optional (via libSRTP)Full media stackFull media stackNot applicable
Binary Size~200 KiB (shared lib)~1 MiB~10 MiB (static)~50 MiB~5 MiB
Dependenciesxbase, usrsctp, OpenSSL or mbedTLSusrsctp, GnuTLS/OpenSSLPure Go (zero CGo)Many (build system)Pure Go
Thread ModelSingle event loop threadInternal thread poolPer-connection goroutinesComplex multi-threadedPer-connection goroutines
API StyleC function pointers (callbacks)C++ lambdas / callbacksGo interfaces / channelsC++ observersGo 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 xEventLoop for I/O multiplexing, xSocket for non-blocking UDP socket management, and timers for ICE connectivity checks and DTLS retransmission.
  • xbuf — Uses xBuffer for SDP string assembly and xIOBuffer for 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

  1. Single Request Struct — All operations share one xFsReq struct. The op field selects the operation; required fields depend on the op. This avoids function explosion (xFsOpen, xFsRead, …) and makes batch submission trivial.

  2. Thread Pool Offload — Filesystem operations are blocking by nature (pread, pwrite, stat, rename). Rather than invent async filesystem syscalls, fs.h delegates to task.h's N:M thread pool. Worker threads perform the actual syscall; the done callback fires on the event loop thread via xEventLoopPost.

  3. Dual Mode (Async / Sync) — When req->cb is non-NULL, xFsReqSubmit returns xErrno_Pending and the callback is invoked on the event loop thread when the operation completes. When cb is 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.

  4. Zero-Copy Buffer Model — The caller owns req->buf and req->path. They must remain valid until the callback fires (async mode) or the call returns (sync mode). No internal copies are made.

  5. Cancellation-Aware — xFsReqCancel delegates to xWorkCancel, which uses CAS to atomically cancel a queued-but-not-yet-running task. After cancel, the callback is NOT invoked and both req and 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

TypeDescription
xFileOpaque file handle. On Unix: int cast to xFile. On Windows: HANDLE.
xFsOpOperation enum: xFsOpOpen, xFsOpClose, xFsOpRead, xFsOpWrite, xFsOpStat, xFsOpMkdir, xFsOpRmdir, xFsOpUnlink, xFsOpRename
xFsStatStat result: size (off_t), mode (int), mtime (uint64_t ms), ctime (uint64_t ms)
xFsReqPer-operation request struct (see below)
xFsFunctypedef void (*xFsFunc)(xFsReq *req) — completion callback

xFsReq

FieldTypeDescription
opxFsOpOperation to perform
pathconst char *File/directory path (Open, Stat, Mkdir, Unlink, Rename, Rmdir)
bufvoid *Data buffer (Read, Write). For Rename: holds the new path string
lensize_tBuffer length (Read, Write)
offsetoff_tFile offset (Read, Write)
flagsintOpen flags: O_RDONLY, `O_CREAT
modeintFile/directory mode: 0644, 0755, etc. (Open, Mkdir)
filexFileFile handle (Close, Read, Write)
cbxFsFuncCompletion callback. NULL = synchronous blocking call
argvoid *User data passed through to callback
resultxErrnoOutput. Operation result: xErrno_Ok on success
retvalssize_tOutput. Bytes read/written, or -1 on error
doneboolOutput. True on the last callback invocation (streaming reads)
statxFsStatOutput. Stat result (xFsOpStat)
out_filexFileOutput. Opened file handle (xFsOpOpen)

Required Fields Per Operation

OperationRequired Input FieldsOutput Fields
xFsOpOpenpath, flags, mode, cbresult, retval, out_file
xFsOpClosefile, cbresult, retval
xFsOpReadfile, buf, len, offset, cbresult, retval, done
xFsOpWritefile, buf, len, offset, cbresult, retval, done
xFsOpStatpath, cbresult, stat
xFsOpMkdirpath, mode, cbresult, retval
xFsOpUnlinkpath, cbresult, retval
xFsOpRmdirpath, cbresult, retval
xFsOpRenamepath (old), buf (new name), cbresult, retval

Functions

FunctionSignatureDescription
xFsReqSubmitxErrno xFsReqSubmit(xFsReq *req)Submit an async or sync filesystem operation. Returns xErrno_Pending for async (callback will fire), or the result directly for sync.
xFsReqCancelxErrno 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

AspectUnixWindows
xFileint fd cast to xFileHANDLE from CreateFile
Open flagsPOSIX `O_CREATO_RDWR` etc.
StatPOSIX stat(2)Windows _stat64
Thread poolpthread-based task.h workerSame 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 to xWorkCancel which uses CAS.
  • Buffers and paths: The caller is responsible for ensuring req->path, req->buf, and the xFsReq itself remain valid until the callback fires (async) or the call returns (sync). No internal copies are made.

Error Handling

Return / ResultMeaning
xErrno_PendingAsync submission accepted. Callback will fire.
xErrno_OkOperation completed successfully (sync mode) or callback reports success.
xErrno_InvalidArgreq is NULL, or required fields are missing.
xErrno_SysErrorUnderlying syscall failed (open, read, write, stat, etc.). Check errno.
xErrno_NoMemoryThread pool queue is full or allocation failed.

See Also

  • task.h — Thread pool (xWork, xWorkSubmit, xWorkCancel) that powers the async path
  • event.h — Event loop where callbacks are delivered
  • error.h — Error code definitions (xErrno)

libxpp

libx