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.