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