Skip to content

Latest commit

 

History

History
817 lines (647 loc) · 49.7 KB

File metadata and controls

817 lines (647 loc) · 49.7 KB

ninep-uniffi-async: Solution Design & Implementation Plan

Revision: Rev 2 — addresses technical review feedback (score 88/100)
Date: 2026-05-04
Status: Draft
Primary reference: ninep-uniffi-async-integration.planner-guide.md


Executive Summary

This plan details the implementation of ninep-uniffi-async, a new UniFFI crate that exposes the async (tokio-backed) 9P2000.L client and server libraries as Swift FFI bindings. The crate produces a cdylib + Swift source consumable by the NinePFSKit Xcode project. The async client provides a smart wrapper (AsyncNineP9Client) that owns a tokio runtime, manages FID allocation, root FID tracking, iounit-based chunked read/write, and exposes both high-level path-based and low-level FID-based async APIs. The async server side provides AsyncNineServerWrapper with AsyncFilesystemCallback as a UniFFI callback interface, bridged to the internal AsyncFilesystem trait via AsyncFilesystemBridge — which handles non-trivial type asymmetries including readdir wire re-encoding. Scope is strictly the FFI layer — no FSKit wiring.


Background

Current Architecture

The workspace (nineprovider-osx) implements a full 9P2000.L protocol suite:

Crate Role
ninep-proto Wire protocol types, encode/decode, Qid/NineStat/DirEntry/StatFs/SetAttr, wire constants (P9_IOHDRSZ, P9_MIN_MSIZE)
ninep-client Sync (SyncNineClient) + async (AsyncNineClient<T>) clients, FidAllocator, tag mux
ninep-server Sync (NineServer) + async (AsyncNineServer) servers, Filesystem/AsyncFilesystem traits
ninep-hostfs Host filesystem implementation of both traits
ninep-serve CLI server binary
ninep-uniffi Existing sync UniFFI bindings: NineClient (Arc<Mutex>), FFI record types, NineError

The existing ninep-uniffi crate exposes a synchronous client only. The NinePFSKit Xcode project currently uses an in-memory stub filesystem (no 9P integration). The FSKit extension needs an async client for non-blocking file operations.

Why Now

The async client (AsyncNineClient) and async server (AsyncNineServer) are complete and integration-tested in Rust. The NinePFSKit FSKit extension needs a non-blocking 9P client. UniFFI 0.31 supports async function export natively, making it the right time to bridge the async layer to Swift.

Prior Art

The ninep-uniffi crate (1063 lines, crates/ninep-uniffi/src/lib.rs) establishes all conventions:

  • uniffi::setup_scaffolding!() macro for proc-macro mode (no .udl file)
  • #[derive(uniffi::Error)] + thiserror::Error for FFI error types
  • #[derive(uniffi::Record)] for data transfer types (Qid, FileStat, etc.)
  • #[derive(uniffi::Object)] + #[uniffi::export] for the client object
  • From impls for converting between proto types and FFI types
  • src/bin/uniffi-bindgen.rs — standard UniFFI bindgen binary (uniffi::uniffi_bindgen_main())
  • Mutex<SyncNineClient> for interior mutability behind Arc
  • Constructor uses P9_MIN_MSIZE (4096) for version negotiation

Problem Statement

  1. No async FFI bindings exist. The FSKit extension requires async I/O (its volume operations use reply handlers); the existing sync UniFFI bindings block OS threads.

  2. AsyncNineClient has a complex API. It exposes raw 9P operations (walk, open, read, clunk) that require callers to manage FIDs, iounit chunking, and operation sequences. Swift callers (especially FSKit) need a higher-level abstraction.

  3. Server FFI is missing. For testing and future use, Swift needs to be able to implement a 9P server via callback interfaces.

  4. Type namespace collision risk. Putting async types in the existing ninep-uniffi crate would force shared type names and couple the sync/async evolution paths.


Goals and Non-Goals

Goals

  1. New crate ninep-uniffi-async — standalone cdylib + lib crate producing libninep_uniffi_async and generating ninep_uniffi_async.swift bindings.
  2. Smart async client wrapper (AsyncNineP9Client) — owns tokio runtime, manages FIDs internally, provides chunked read/write, exposes both high-level path-based and low-level FID-based async APIs.
  3. Server wrapper (AsyncNineServerWrapper) — exposes AsyncFilesystemCallback as a UniFFI callback interface with AsyncFilesystemBridge adapter.
  4. All FFI record typesAsyncQid, AsyncFileStat, AsyncDirEntry, AsyncSetAttrInput, AsyncFsStats, AsyncOpenResult, AsyncCreateResult, plus server response records (AsyncRattach, AsyncRwalk, AsyncRlopen, AsyncRlcreate).
  5. AsyncNineError — rich error enum with errno preservation and tracing.
  6. Swift binding generationuniffi-bindgen generate producing .swift, .h, .modulemap files.
  7. Validation — Rust unit tests (smart wrapper, callback bridge), Swift compilation check, Rust integration test (async client ↔ async server through FFI wrappers).

Non-Goals

  • FSKit wiring — Volume.swift, Item.swift integration is a separate plan (§8 of guide is future reference only).
  • FID caching — walk-per-lookup is correct for PoC; path→FID cache is future optimization.
  • Pipelined/concurrent client — sequential tokio::sync::Mutex is acceptable for PoC.
  • Shared runtime — per-object runtime is correct for single-client-per-volume use case.
  • Sync server FFINineServer + Filesystem callback in the existing ninep-uniffi crate (guide Phase 3) is out of scope for this plan.

Requirements

Functional

ID Requirement
F1 TCP and Unix socket constructors with automatic version+attach bootstrap
F2 High-level path-based methods: read_file, write_file, list_dir, stat, set_stat, make_dir, create_file, remove_path, create_symlink, read_symlink, create_link, rename_path, fs_sync, stat_fs, shutdown
F3 Low-level FID-based methods: walk, open, read, write, clunk, getattr, setattr, readdir_parsed, mkdir, symlink, link, rename, renameat, unlinkat, readlink, fsync
F4 Chunked read/write transparent to Swift (splits by iounit)
F5 Server wrapper with TCP/Unix serve + graceful shutdown
F6 AsyncFilesystemCallback trait as UniFFI callback interface with 18 methods (subset of 27 total AsyncFilesystem methods; 9 excluded methods have default ENOTSUP/no-op impls)
F7 AsyncFilesystemBridge adapting callback to internal AsyncFilesystem trait
F8 All FFI record types with bidirectional From conversions to/from proto types
F9 Generated Swift bindings compile cleanly

Non-Functional

ID Requirement
NF1 All async methods generate Swift async throws signatures
NF2 Errors preserve POSIX errno from 9P server for FSKit error mapping
NF3 tracing logs for all error paths
NF4 No unsafe code in the FFI crate
NF5 BytesVec<u8> conversion at FFI boundary (one copy per operation, acceptable for PoC)

Proposed Design

Architecture Overview

┌────────────────────────────────────────────────────────────────┐
│  NinePFSKit (Xcode) — imports ninep_uniffi_async.swift         │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  NinePFSKitExtension (future: Volume.swift uses client) │   │
│  └─────────────────────────────────────────────────────────┘   │
└───────────────────────────┬────────────────────────────────────┘
                            │ FFI (C ABI via UniFFI)
                            ▼
┌────────────────────────────────────────────────────────────────┐
│  ninep-uniffi-async (cdylib + lib)                             │
│                                                                │
│  ┌──────────────────────────────────────────────────────┐      │
│  │ AsyncNineP9Client (#[uniffi::Object])                │      │
│  │  runtime: tokio::runtime::Runtime                    │      │
│  │  client: Arc<tokio::sync::Mutex<                     │      │
│  │            AsyncNineClient<AnyAsyncTransport>>>       │      │
│  │  root_fid: AtomicU32                                 │      │
│  │  iounit: AtomicU32                                   │      │
│  │  msize: AtomicU32                                    │      │
│  │                                                      │      │
│  │  High-level: read_file, write_file, list_dir, ...    │      │
│  │  Low-level:  walk, open, read, write, clunk, ...     │      │
│  └──────────────────────────────────────────────────────┘      │
│                                                                │
│  ┌──────────────────────────────────────────────────────┐      │
│  │ AsyncNineServerWrapper (#[uniffi::Object])           │      │
│  │  runtime: tokio::runtime::Runtime                    │      │
│  │  cancel: CancellationToken                           │      │
│  │                                                      │      │
│  │  serve_tcp(addr, callback)                           │      │
│  │  serve_unix(path, callback)                          │      │
│  │  shutdown()                                          │      │
│  └──────────────────────────────────────────────────────┘      │
│                                                                │
│  AsyncFilesystemCallback (callback_interface trait)             │
│  AsyncFilesystemBridge (AsyncFilesystem adapter)               │
│                                                                │
│  FFI Records: AsyncQid, AsyncFileStat, AsyncDirEntry, ...     │
│  FFI Error:   AsyncNineError                                  │
│                                                                │
│  Depends on: ninep-client[tokio], ninep-server[tokio],         │
│              ninep-proto, uniffi 0.31                           │
└────────────────────────────────────────────────────────────────┘

Key Components

1. Transport Abstraction: AnyAsyncTransport

AsyncNineClient<T> is generic over T: AsyncTransport. The AsyncTransport trait uses RPITIT (impl Future return types), making it non-object-safe. We need a concrete type for the FFI wrapper.

Solution: An enum wrapper that dispatches to both transport types:

enum AnyAsyncTransport {
    Tcp(AsyncTcpTransport),
    #[cfg(unix)]
    Unix(AsyncUnixTransport),
}

impl AsyncTransport for AnyAsyncTransport {
    async fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()> {
        match self {
            Self::Tcp(t) => t.read_exact(buf).await,
            #[cfg(unix)]
            Self::Unix(t) => t.read_exact(buf).await,
        }
    }
    // ... write_all, flush, shutdown
}

Rationale: This avoids boxing futures, keeps zero overhead on the hot path, and is invisible to Swift callers. The enum is internal to the crate — Swift sees only AsyncNineP9Client with connect() / connect_unix() constructors.

2. AsyncNineP9Client — Smart Client Wrapper

The central FFI object. Owns a tokio runtime, wraps the async client behind tokio::sync::Mutex, and manages connection lifecycle.

Structure (per Appendix B decisions #1, #2, #3, #6, #12, #13, #19):

#[derive(uniffi::Object)]
pub struct AsyncNineP9Client {
    runtime: tokio::runtime::Runtime,
    client: Arc<tokio::sync::Mutex<AsyncNineClient<AnyAsyncTransport>>>,
    root_fid: AtomicU32,
    iounit: AtomicU32,
    msize: AtomicU32,
}

Async method pattern: Each exported async method spawns work on the owned runtime and awaits the JoinHandle. This is necessary because UniFFI's foreign executor (Swift's async/await) polls the Rust future, but the underlying AsyncNineClient requires a tokio runtime context for I/O operations.

#[uniffi::export]
impl AsyncNineP9Client {
    pub async fn read_file(&self, path: String, offset: u64, count: u32)
        -> Result<Vec<u8>, AsyncNineError>
    {
        let handle = self.runtime.handle().clone();
        let client = self.client.clone();
        let iounit = self.iounit.load(Ordering::Relaxed);
        let root_fid = self.root_fid.load(Ordering::Relaxed);

        handle.spawn(async move {
            let mut c = client.lock().await;
            let components = parse_path(&path);
            let (fid, _) = c.walk_auto(root_fid, &components).await?;
            let rlopen = c.open(fid, 0 /* O_RDONLY */).await?;
            let effective_iounit = if rlopen.iounit > 0 { rlopen.iounit } else { iounit };
            let data = chunked_read(&mut c, fid, offset, count, effective_iounit).await?;
            c.clunk(fid).await?;
            Ok(data)
        }).await.map_err(|e| AsyncNineError::IoError { msg: e.to_string() })?
    }
}

Constructors: connect(address) and connect_unix(path) are constructors that:

  1. Build a tokio::runtime::Runtime (multi-thread, default thread count)
  2. Connect the transport (TCP or Unix)
  3. Perform Tversion negotiation (stores msize)
  4. Perform Tattach with root FID 0 (stores root Qid, tracks root FID)
  5. Store the negotiated iounit (from attach/initial msize)

Since constructors cannot be async in UniFFI (they return Arc<Self>), the constructor uses runtime.block_on() for the bootstrap sequence:

use ninep_proto::wire::{P9_IOHDRSZ, P9_MIN_MSIZE, NOFID};

#[uniffi::constructor]
pub fn connect(address: String) -> Result<Arc<Self>, AsyncNineError> {
    let runtime = tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .map_err(|e| AsyncNineError::IoError { msg: e.to_string() })?;

    let (client, msize) = runtime.block_on(async {
        let transport = AsyncTcpTransport::connect(&address).await?;
        let mut client = AsyncNineClient::new(AnyAsyncTransport::Tcp(transport));
        // Use P9_MIN_MSIZE (4096) to match the existing sync crate's convention.
        // The server may negotiate a larger value; the returned msize is the
        // negotiated result (min of offered and server's max).
        let msize = client.version(P9_MIN_MSIZE).await?;
        let _rattach = client.attach(0, NOFID, "", "", 0).await?;
        Ok::<_, AsyncNineError>((client, msize))
    })?;

    // Default iounit: msize minus I/O header overhead.
    // P9_IOHDRSZ = 24 is defined in ninep_proto::wire as:
    //   header(7) + fid(4) + offset(8) + count(4) + padding(1) = 24
    let iounit = msize - P9_IOHDRSZ;
    Ok(Arc::new(Self {
        runtime,
        client: Arc::new(tokio::sync::Mutex::new(client)),
        root_fid: AtomicU32::new(0),
        iounit: AtomicU32::new(iounit),
        msize: AtomicU32::new(msize),
    }))
}

Note on msize synchronization: The AsyncNineClient internally stores self.msize after version() negotiation (line 192 of async_client.rs). The FFI wrapper's msize: AtomicU32 field is initialized from the same return value and stays consistent because version() is only called once during construction. Post-construction, the wrapper reads self.msize for chunking calculations while the internal client uses its own self.msize for frame encoding — both values are identical and immutable after the constructor.

3. Hybrid API Surface

Per Appendix B decision #19, the client exposes two tiers:

High-level (path-based) — handles walk→open→operation→clunk internally:

Method Signature Internal 9P Sequence
connect (address: String) → Self connect → Tversion → Tattach
connect_unix (path: String) → Self same
read_file (path, offset, count) → Vec<u8> walk → lopen → chunked read → clunk
write_file (path, offset, data) → u32 walk → lopen(WR) → chunked write → clunk
list_dir (path) → Vec<AsyncDirEntry> walk → lopen → readdir_parsed → clunk
stat (path) → AsyncFileStat walk → getattr → clunk
set_stat (path, attr) → () walk → setattr → clunk
make_dir (path, mode, gid) → AsyncQid walk(parent) → mkdir → clunk
create_file (path, flags, mode, gid) → AsyncCreateResult walk(parent) → lcreate → clunk
remove_path (path) → () walk(parent) → unlinkat → clunk
create_symlink (path, target, gid) → AsyncQid walk(parent) → symlink → clunk
read_symlink (path) → String walk → readlink → clunk
create_link (path, target_path) → () walk(target) + walk(parent) → link → clunk both
rename_path (old_path, new_path) → () walk(old_parent) + walk(new_parent) → renameat → clunk both
fs_sync (path, datasync: bool) → () walk → lopen → fsync → clunk
stat_fs () → AsyncFsStats statfs(root_fid)
shutdown () → () drain FIDs → close transport

Low-level (FID-based) — FID allocated internally, caller controls open/close lifecycle:

Method Signature
walk (parent_fid, names) → (u32, Vec<AsyncQid>)
open (fid, flags) → AsyncOpenResult
read (fid, offset, count) → Vec<u8>
write (fid, offset, data) → u32
clunk (fid) → ()
getattr (fid, mask) → AsyncFileStat
setattr (fid, attr) → ()
readdir_parsed (fid, offset, count) → Vec<AsyncDirEntry>
mkdir (parent_fid, name, mode, gid) → AsyncQid
symlink (parent_fid, name, target, gid) → AsyncQid
link (parent_fid, target_fid, name) → ()
renameat (old_parent_fid, old_name, new_parent_fid, new_name) → ()
unlinkat (parent_fid, name, flags) → ()
readlink (fid) → String
fsync (fid, datasync: bool) → ()
get_msize () → u32
get_root_fid () → u32

Note: Low-level read/write still chunk internally (Appendix B decisions #12, #13) — the chunking is always Rust-side.

readdir_parsed implementation detail: AsyncNineClient::readdir() returns raw ClientResult<Bytes>, not parsed entries. The readdir_parsed method must perform DirEntry::decode() byte parsing, exactly as the existing sync crate does (lines 499-518 of ninep-uniffi/src/lib.rs):

// readdir_parsed implementation pattern (in client_lowlevel.rs):
pub async fn readdir_parsed(&self, fid: u32, offset: u64, count: u32)
    -> Result<Vec<AsyncDirEntry>, AsyncNineError>
{
    let handle = self.runtime.handle().clone();
    let client = self.client.clone();
    handle.spawn(async move {
        let mut c = client.lock().await;
        let data = c.readdir(fid, offset, count).await?;
        let mut buf = data;
        let mut entries = Vec::new();
        while !buf.is_empty() {
            let entry = ninep_proto::stat::DirEntry::decode(&mut buf)
                .map_err(|e| AsyncNineError::ProtocolError {
                    msg: format!("failed to decode directory entry: {e}"),
                })?;
            entries.push(AsyncDirEntry::from(entry));
        }
        Ok(entries)
    }).await.map_err(|e| AsyncNineError::IoError { msg: e.to_string() })?
}

4. AsyncNineServerWrapper — Server FFI

Per Appendix B decision #4 (server for future use) and #17 (full callback interface):

#[derive(uniffi::Object)]
pub struct AsyncNineServerWrapper {
    runtime: tokio::runtime::Runtime,
    cancel: CancellationToken,
}

Methods:

  • serve_tcp(address: String, fs: Arc<dyn AsyncFilesystemCallback>) — binds TCP, starts accept loop
  • serve_unix(path: String, fs: Arc<dyn AsyncFilesystemCallback>) — binds Unix socket
  • shutdown() — cancels the token, stops accepting

Concurrency model — shared callback instance: AsyncNineServer::serve_tcp() takes impl Fn() -> Arc<dyn AsyncFilesystem> — a factory that creates one AsyncFilesystem instance per connection. The FFI wrapper's serve_tcp(address, callback) takes a single Arc<dyn AsyncFilesystemCallback>. The bridge creates the factory closure by cloning the Arc<dyn AsyncFilesystemCallback> for each connection:

pub async fn serve_tcp(&self, address: String, callback: Arc<dyn AsyncFilesystemCallback>)
    -> Result<(), AsyncNineError>
{
    let listener = TcpListener::bind(&address).await?;
    let server = AsyncNineServer::new(self.cancel.clone());
    // Factory closure: clones the callback Arc for each new connection.
    // All connections share the SAME callback instance — the Swift implementor
    // must handle concurrent calls from multiple connections safely (Send + Sync).
    server.serve_tcp(listener, move || {
        Arc::new(AsyncFilesystemBridge::new(callback.clone()))
            as Arc<dyn AsyncFilesystem>
    }).await?;
    Ok(())
}

This means all connections share one callback instance rather than having independent instances. The Swift callback implementor must be thread-safe (guaranteed by Send + Sync requirement on the callback interface). If per-connection isolation is needed in the future, the callback interface can be extended with a create_session() factory method.

5. AsyncFilesystemCallback — UniFFI Callback Interface

Per Appendix B decision #17, a full callback interface with ~18 methods:

#[uniffi::export(callback_interface)]
pub trait AsyncFilesystemCallback: Send + Sync {
    fn attach(&self, fid: u32, afid: u32, uname: String, aname: String, n_uname: u32)
        -> Result<AsyncRattach, AsyncNineError>;
    fn walk(&self, fid: u32, newfid: u32, wnames: Vec<String>)
        -> Result<AsyncRwalk, AsyncNineError>;
    fn lopen(&self, fid: u32, flags: u32) -> Result<AsyncRlopen, AsyncNineError>;
    fn lcreate(&self, fid: u32, name: String, flags: u32, mode: u32, gid: u32)
        -> Result<AsyncRlcreate, AsyncNineError>;
    fn read(&self, fid: u32, offset: u64, count: u32) -> Result<Vec<u8>, AsyncNineError>;
    fn write(&self, fid: u32, offset: u64, data: Vec<u8>) -> Result<u32, AsyncNineError>;
    fn clunk(&self, fid: u32) -> Result<(), AsyncNineError>;
    fn getattr(&self, fid: u32, request_mask: u64) -> Result<AsyncFileStat, AsyncNineError>;
    fn setattr(&self, fid: u32, attr: AsyncSetAttrInput) -> Result<(), AsyncNineError>;
    fn readdir(&self, fid: u32, offset: u64, count: u32) -> Result<Vec<AsyncDirEntry>, AsyncNineError>;
    fn statfs(&self, fid: u32) -> Result<AsyncFsStats, AsyncNineError>;
    fn mkdir(&self, dfid: u32, name: String, mode: u32, gid: u32) -> Result<AsyncQid, AsyncNineError>;
    fn symlink(&self, dfid: u32, name: String, target: String, gid: u32) -> Result<AsyncQid, AsyncNineError>;
    fn link(&self, dfid: u32, fid: u32, name: String) -> Result<(), AsyncNineError>;
    fn renameat(&self, olddirfid: u32, oldname: String, newdirfid: u32, newname: String)
        -> Result<(), AsyncNineError>;
    fn unlinkat(&self, dirfid: u32, name: String, flags: u32) -> Result<(), AsyncNineError>;
    fn readlink(&self, fid: u32) -> Result<String, AsyncNineError>;
    fn fsync(&self, fid: u32, datasync: u32) -> Result<(), AsyncNineError>;
}

Note on sync vs async callbacks: UniFFI callback interfaces in 0.31 are synchronous by default. The async_runtime = "tokio" attribute is for exported async functions (telling UniFFI to poll the future on a tokio runtime), not for callback interfaces — callbacks are always synchronous from UniFFI's perspective. The bridge layer wraps each call in tokio::task::spawn_blocking to avoid blocking the tokio runtime:

struct AsyncFilesystemBridge {
    callback: Arc<dyn AsyncFilesystemCallback>,
}

#[async_trait]
impl AsyncFilesystem for AsyncFilesystemBridge {
    async fn attach(&self, fid: u32, afid: u32, uname: &str, aname: &str, n_uname: u32)
        -> io::Result<Rattach>
    {
        let cb = self.callback.clone();
        let uname = uname.to_string();
        let aname = aname.to_string();
        let result = tokio::task::spawn_blocking(move || {
            cb.attach(fid, afid, uname, aname, n_uname)
        }).await.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
        result.map(|r| Rattach { qid: r.qid.into() })
              .map_err(|e| async_nine_error_to_io(e))
    }
    // ... for each method
}

readdir bridge asymmetry — CRITICAL IMPLEMENTATION DETAIL: The AsyncFilesystem::readdir() returns io::Result<Rreaddir> where Rreaddir contains raw Bytes wire data. But the AsyncFilesystemCallback::readdir() returns Vec<AsyncDirEntry> (parsed records, more ergonomic for Swift). The bridge must re-encode the parsed entries back into Rreaddir wire format:

// In AsyncFilesystemBridge::readdir():
async fn readdir(&self, fid: u32, offset: u64, count: u32) -> io::Result<Rreaddir> {
    let cb = self.callback.clone();
    let entries = tokio::task::spawn_blocking(move || {
        cb.readdir(fid, offset, count)
    }).await.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?
      .map_err(async_nine_error_to_io)?;

    // Re-encode Vec<AsyncDirEntry> → Rreaddir wire bytes
    let mut buf = BytesMut::new();
    for entry in entries {
        let proto_entry: DirEntry = entry.into();
        proto_entry.encode(&mut buf)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
    }
    Ok(Rreaddir { data: buf.freeze() })
}

This re-encoding uses DirEntry::encode() (from ninep_proto::stat) and NineEncode trait. The overhead is acceptable — the callback already crosses an FFI boundary, so one extra encode pass is negligible.

write bridge type conversion: AsyncFilesystem::write() takes data: &Bytes while the callback has data: Vec<u8>. The bridge converts Vec<u8>Bytes via Bytes::from(data) (zero-copy when possible).

Excluded AsyncFilesystem methods: The AsyncFilesystem trait has 27 methods total (25 async + 2 sync). The callback interface exposes 18 methods — the following 9 are excluded because they all have default implementations in the trait that return ENOTSUP or no-op, and are not needed for typical filesystem operations:

Excluded Method Default Behavior Reason
auth Returns ENOTSUP Authentication rarely needed for local 9P
remove Returns ENOTSUP Deprecated in 9P2000.L; unlinkat is preferred
flush Returns Ok(()) Request cancellation; no-op default is sufficient
mknod Returns ENOTSUP Device nodes not relevant for FSKit use case
lock Returns ENOTSUP File locking not needed for PoC
getlock Returns ENOTSUP File locking not needed for PoC
xattrwalk Returns ENOTSUP Extended attributes not needed for PoC
xattrcreate Returns ENOTSUP Extended attributes not needed for PoC
rename Returns ENOTSUP Old-style non-atomic rename; renameat is preferred

The 2 non-async methods (set_msize, reset) are handled internally by the bridge and not exposed to Swift. The bridge implements them as no-ops (matching the trait defaults).

6. FFI Record Types

All record types are Async-prefixed and independent from the sync crate (Appendix B decision #11):

FFI Record Proto Source Direction
AsyncQid ninep_proto::qid::Qid bidirectional
AsyncOpenResult Rlopen proto → FFI
AsyncCreateResult (Qid, u32) from Rlcreate proto → FFI
AsyncFileStat NineStat proto → FFI
AsyncSetAttrInput SetAttr FFI → proto
AsyncFsStats StatFs proto → FFI
AsyncDirEntry DirEntry proto → FFI
AsyncRattach server callback response FFI → proto
AsyncRwalk server callback response FFI → proto
AsyncRlopen server callback response FFI → proto
AsyncRlcreate server callback response FFI → proto

Each gets bidirectional From impls mirroring the existing sync crate's pattern.

7. Error Type

Per Appendix B decision #9:

#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum AsyncNineError {
    #[error("Connection failed: {msg}")]
    ConnectionFailed { msg: String },

    #[error("Server error (errno={errno}): {msg}")]
    ServerError { errno: u32, msg: String },

    #[error("Protocol error: {msg}")]
    ProtocolError { msg: String },

    #[error("I/O error: {msg}")]
    IoError { msg: String },

    #[error("Lock poisoned: {msg}")]
    LockPoisoned { msg: String },
}

With From<ClientError> and From<io::Error> conversions, plus tracing::warn! for server errors.

Data Flow

Client Read File (high-level)

Swift: try await client.readFile(path: "/foo/bar.txt", offset: 0, count: 8192)
  │
  ▼ UniFFI async bridge (foreign executor polls RustFuture)
  │
  ▼ AsyncNineP9Client::read_file()
  │  └─ runtime.handle().spawn(async { ... })
  │     ├─ client.lock().await
  │     ├─ client.walk_auto(root_fid, ["foo", "bar.txt"])  → new FID
  │     ├─ client.open(fid, O_RDONLY)                       → iounit
  │     ├─ chunked_read(client, fid, 0, 8192, iounit)
  │     │   └─ loop: client.read(fid, off, min(remaining, iounit))
  │     │      └─ [TCP] Tread → Rread (possibly multiple rounds)
  │     ├─ client.clunk(fid)
  │     └─ return Vec<u8>
  │
  ▼ UniFFI → Swift Data

Server Callback (attach)

9P Client connects → TCP accept → AsyncSession
  │
  ▼ Session receives Tattach
  │
  ▼ AsyncFilesystemBridge::attach()
  │  └─ spawn_blocking { callback.attach(fid, afid, uname, aname, n_uname) }
  │     └─ UniFFI invokes Swift callback implementation
  │        └─ Swift returns AsyncRattach { qid: AsyncQid(...) }
  │     └─ Convert AsyncRattach → Rattach
  │
  ▼ Session sends Rattach to client

Design Decisions

All decisions from Appendix B are followed strictly. Key ones with implementation implications:

# Decision Implementation Impact
1 tokio::sync::Mutex All async methods lock the mutex; yields task while waiting
2 Per-object tokio runtime Constructor builds Runtime::new_multi_thread()
3 TCP + Unix transports AnyAsyncTransport enum dispatches to both
6 Rust-side FID management walk_auto() allocates FIDs; high-level API clunks after use
11 Separate types per crate All types Async-prefixed, independent From impls
12/13 Rust-side chunking Internal chunked_read/chunked_write helpers
14 Direct reference Xcode integration Generated files in target/uniffi-swift/
19 Hybrid API Both path-based and FID-based method sets

Dependencies

External Dependencies

Dependency Version Purpose
uniffi 0.31 FFI code generation, proc-macros, CLI
tokio 1.x Async runtime, networking, sync primitives
tokio-util 0.7.x CancellationToken for server shutdown
thiserror 2.x Error derive macros
tracing 0.1.x Structured logging
async-trait 0.1.x AsyncFilesystem trait in server bridge
bytes 1.x Bytes type used by proto/client (transitive)

Internal Dependencies

Crate Features Purpose
ninep-proto default Wire types: Qid, NineStat, SetAttr, DirEntry, StatFs, messages, constants (P9_IOHDRSZ, P9_MIN_MSIZE, NOFID), NineEncode/NineDecode traits
ninep-client tokio AsyncNineClient, AsyncTcpTransport, AsyncUnixTransport, FidAllocator, ClientError
ninep-server tokio AsyncNineServer, AsyncFilesystem trait

Sequencing Constraints

  • No external dependencies — all internal crates are already feature-complete.
  • UniFFI 0.31 is already used by ninep-uniffi (version alignment guaranteed).
  • The workspace Cargo.toml must be updated to include ninep-uniffi-async in members.

Risks and Mitigations

Risk Likelihood Impact Mitigation
UniFFI async callback interfaces have edge cases in 0.31 Medium High Start with sync callbacks wrapped in spawn_blocking. Test with a minimal 3-method subset first.
tokio::JoinHandle polling from foreign executor fails Low High Verified in UniFFI docs — JoinHandle implements std::future::Future and is executor-agnostic. Fall back to channel-based bridge if needed.
AnyAsyncTransport enum adds complexity Low Low Only 4 methods to dispatch; compile-time verified.
Large callback interface (18 methods) is tedious/error-prone Medium Medium Use a macro for repetitive From conversions and bridge method delegation.
BytesVec<u8> copy overhead for large reads/writes Low Medium Acceptable for PoC. Profile later; zero-copy optimization documented in guide §10.
readdir bridge re-encoding (Vec<AsyncDirEntry>Rreaddir wire bytes) adds complexity and could have subtle encoding bugs Medium High Reuse existing DirEntry::encode() from ninep_proto::stat. Add dedicated round-trip unit test: encode → decode → compare. If too fragile, consider changing callback to return raw Vec<u8> instead.
Shared callback instance across connections may cause contention Low Medium Send + Sync requirement is enforced by UniFFI callback interface. Document that Swift implementors must be thread-safe. Per-session factory is a future enhancement.

Open Questions

  1. Constructor async bootstrap blocking: UniFFI constructors return Arc<Self> synchronously. The constructor uses runtime.block_on() for version+attach. This briefly blocks the calling Swift thread. Verify this is acceptable for FSKit's activate() context. If not, consider a two-phase init pattern: new()connect() async method.

  2. walk return type for low-level API: The guide says walk(parent_fid, names) → (u32, Vec<AsyncQid>). UniFFI doesn't support tuple returns natively. Resolution: Return an AsyncWalkResult { fid: u32, qids: Vec<AsyncQid> } record instead.

  3. staticlib vs cdylib: The guide notes FSKit extensions are app extensions. Sandboxed contexts may have trouble loading dynamic libraries. The current plan uses cdylib (matching the sync crate). If linking fails, switch to staticlib.


Implementation Phases

Phase 1: Crate Scaffold & FFI Types

Exit criteria: cargo build -p ninep-uniffi-async succeeds. All record types, error enum, and conversion helpers compile. No exported methods yet.

Phase 2: Async Client Smart Wrapper

Exit criteria: AsyncNineP9Client with constructors, full high-level and low-level API. Rust unit tests pass for type conversions and path parsing. Generated Swift bindings compile.

Phase 3: Async Server Wrapper & Callback Interface

Exit criteria: AsyncNineServerWrapper, AsyncFilesystemCallback, and AsyncFilesystemBridge compile. Unit test verifying callback bridge round-trip (Rust struct implements callback interface, calls bridge, verifies type conversions).

Phase 4: Bindgen & Xcode Integration

Exit criteria: uniffi-bindgen generate produces valid .swift, .h, .modulemap. Swift bindings compile with swiftc.

Phase 5: Validation & Integration Tests

Exit criteria: Rust integration test passes — async client connects to async server through FFI wrapper, performs walk + read + write cycle. All unit tests pass.


Files Affected

New Files

File Path Purpose
crates/ninep-uniffi-async/Cargo.toml Crate manifest with dependencies
crates/ninep-uniffi-async/src/lib.rs Main FFI module: scaffolding, re-exports
crates/ninep-uniffi-async/src/error.rs AsyncNineError enum + From conversions
crates/ninep-uniffi-async/src/types.rs All Async* FFI record types + From impls
crates/ninep-uniffi-async/src/transport.rs AnyAsyncTransport enum
crates/ninep-uniffi-async/src/client.rs AsyncNineP9Client smart wrapper
crates/ninep-uniffi-async/src/client_highlevel.rs High-level path-based methods
crates/ninep-uniffi-async/src/client_lowlevel.rs Low-level FID-based methods
crates/ninep-uniffi-async/src/server.rs AsyncNineServerWrapper
crates/ninep-uniffi-async/src/callback.rs AsyncFilesystemCallback trait + AsyncFilesystemBridge
crates/ninep-uniffi-async/src/bin/uniffi-bindgen.rs UniFFI bindgen binary
crates/ninep-uniffi-async/tests/integration.rs Async client ↔ async server integration test through FFI wrappers

Modified Files

File Path Changes
Cargo.toml (workspace root) Add "crates/ninep-uniffi-async" to workspace.members

Deleted Files

None.


Implementation Plan

Epic 1: Crate Scaffold & FFI Types — ✅ DONE

Goal: Create the ninep-uniffi-async crate with all FFI-safe types, error enum, and conversion helpers. The crate compiles successfully.

Status: DONE
Completed: 2026-05-04
Notes: All 26 tests pass, clippy zero warnings, build clean. Bidirectional From impls added for AsyncRattach, AsyncRwalk, AsyncRlopen, AsyncRlcreate with roundtrip tests. AnyAsyncTransport visibility fixed (pub→pub(crate)), redundant tokio dev-dep removed, compile-time AsyncTransport trait assertion added.

Prerequisites: None.

Task ID Type Description Files Status
E1-T1 IMPL Create Cargo.toml with all dependencies (ninep-proto, ninep-client[tokio], ninep-server[tokio], uniffi 0.31 with cli + tokio features, tokio, thiserror, tracing, async-trait, tokio-util, bytes). Set crate-type = ["cdylib", "lib"]. The bytes crate is a direct dependency (needed for BytesMut/Bytes in the readdir bridge re-encoding). crates/ninep-uniffi-async/Cargo.toml DONE
E1-T2 IMPL Add "crates/ninep-uniffi-async" to workspace members in root Cargo.toml. Cargo.toml DONE
E1-T3 IMPL Create src/lib.rs with uniffi::setup_scaffolding!(), module declarations (mod error; mod types; mod transport; mod client; mod client_highlevel; mod client_lowlevel; mod server; mod callback;), and public re-exports. crates/ninep-uniffi-async/src/lib.rs DONE
E1-T4 IMPL Create src/error.rs: AsyncNineError enum with #[derive(uniffi::Error, thiserror::Error)], From<ClientError>, From<io::Error>. Include tracing::warn for ServerError. crates/ninep-uniffi-async/src/error.rs DONE
E1-T5 IMPL Create src/types.rs: All FFI record types — AsyncQid, AsyncOpenResult, AsyncCreateResult, AsyncFileStat, AsyncSetAttrInput, AsyncFsStats, AsyncDirEntry, AsyncWalkResult, AsyncRattach, AsyncRwalk, AsyncRlopen, AsyncRlcreate. Each with #[derive(uniffi::Record)] and bidirectional From impls to/from proto types. crates/ninep-uniffi-async/src/types.rs DONE
E1-T6 IMPL Create src/transport.rs: AnyAsyncTransport enum (Tcp, Unix variants) implementing AsyncTransport. crates/ninep-uniffi-async/src/transport.rs DONE
E1-T7 IMPL Create src/bin/uniffi-bindgen.rs (identical to sync crate: fn main() { uniffi::uniffi_bindgen_main() }). crates/ninep-uniffi-async/src/bin/uniffi-bindgen.rs DONE
E1-T8 IMPL Create stub src/client.rs, src/client_highlevel.rs, src/client_lowlevel.rs, src/server.rs, src/callback.rs (empty modules or minimal structs to satisfy mod declarations). Multiple DONE
E1-T9 TEST Verify cargo build -p ninep-uniffi-async succeeds. Verify cargo test -p ninep-uniffi-async passes (unit tests for From conversions and error mapping). DONE

Acceptance Criteria:

  • cargo build -p ninep-uniffi-async compiles without errors
  • All FFI record types have bidirectional From impls tested
  • AsyncNineError conversion from ClientError tested for all variants
  • AnyAsyncTransport dispatches to both TCP and Unix transports

Epic 2: Async Client Smart Wrapper — ✅ DONE

Goal: Implement AsyncNineP9Client with constructors, low-level FID-based methods, and high-level path-based methods including chunked read/write.

Status: DONE
Completed: 2026-05-04
Notes: All 47 tests pass. Critical FID leak fixes applied: create_link and rename_path now validate paths (via split_parent_name) before allocating any FIDs. fs_sync open flags corrected O_RDWR→O_WRONLY for write-only file compatibility. write_chunk_size doc comment added explaining the defense-in-depth data_len=0 guard. write_chunks_empty_data_early_return test replaced with meaningful assertions verifying both the empty check and the safety guard interaction.

Prerequisites: Epic 1 complete.

Task ID Type Description Files Status
E2-T1 IMPL Implement AsyncNineP9Client struct in src/client.rs: #[derive(uniffi::Object)], runtime/client/root_fid/iounit/msize fields. Add connect() constructor (TCP) with block_on bootstrap (version + attach). Add connect_unix() constructor. Add internal helper parse_path(path: &str) -> Vec<String> to split "/" paths into components. crates/ninep-uniffi-async/src/client.rs DONE
E2-T2 IMPL Implement low-level methods in src/client_lowlevel.rs: walk, open, read (chunked), write (chunked), clunk, getattr, setattr, readdir_parsed, mkdir, symlink, link, renameat, unlinkat, readlink, fsync, get_msize, get_root_fid. Each exported as #[uniffi::export] async method. Internal chunked_read and chunked_write helper functions. readdir_parsed must include DirEntry::decode() byte parsing — call client.readdir() (returns raw Bytes), then loop with DirEntry::decode(&mut buf) to produce Vec<AsyncDirEntry>, following the exact pattern from sync crate lines 499-518. Use P9_IOHDRSZ from ninep_proto::wire for iounit default calculation. crates/ninep-uniffi-async/src/client_lowlevel.rs DONE
E2-T3 IMPL Implement high-level methods in src/client_highlevel.rs: read_file, write_file, list_dir, stat, set_stat, make_dir, create_file, remove_path, create_symlink, read_symlink, create_link, rename_path, fs_sync, stat_fs, shutdown. Each handles full walk→op→clunk sequence internally. crates/ninep-uniffi-async/src/client_highlevel.rs DONE
E2-T4 TEST Unit tests in src/client.rs or inline: test parse_path for edge cases (root "/", trailing slash, empty components, relative path). Test chunked_read/chunked_write logic with mock scenarios. crates/ninep-uniffi-async/src/client.rs DONE
E2-T5 TEST Verify cargo build -p ninep-uniffi-async succeeds with all client code. Run cargo test. DONE

Acceptance Criteria:

  • AsyncNineP9Client compiles with all methods exported
  • parse_path correctly handles /, /foo/bar, foo/bar, trailing slashes
  • Chunked read/write logic correctly splits by iounit
  • Both TCP and Unix constructors are implemented
  • shutdown() drains FIDs and closes transport

Epic 3: Async Server Wrapper & Callback Interface

Goal: Implement AsyncNineServerWrapper, AsyncFilesystemCallback trait, and AsyncFilesystemBridge adapter.

Prerequisites: Epic 1 complete.

Status: DONE

Task ID Type Description Files Status
E3-T1 IMPL Implement AsyncNineServerWrapper in src/server.rs: #[derive(uniffi::Object)], runtime + cancel token. new() constructor, serve_tcp(address, callback), serve_unix(path, callback), shutdown() methods. Each serve_* spawns the AsyncNineServer accept loop on the owned runtime. The factory closure clones the callback Arc for each connection (all connections share one callback instance — see design section for concurrency model details). crates/ninep-uniffi-async/src/server.rs DONE
E3-T2 IMPL Implement AsyncFilesystemCallback trait in src/callback.rs: #[uniffi::export(with_foreign)] with 18 methods (attach, walk, lopen, lcreate, read, write, clunk, getattr, setattr, readdir, statfs, mkdir, symlink, link, renameat, unlinkat, readlink, fsync). All return Result<T, AsyncNineError>. The 9 excluded methods (auth, remove, flush, mknod, lock, getlock, xattrwalk, xattrcreate, rename) retain default ENOTSUP/no-op implementations in the bridge. crates/ninep-uniffi-async/src/callback.rs DONE
E3-T3 IMPL Implement AsyncFilesystemBridge in src/callback.rs: struct wrapping Arc<dyn AsyncFilesystemCallback>, implementing #[async_trait] AsyncFilesystem. Each method delegates via tokio::task::spawn_blocking, converts FFI types ↔ proto types. Include helper async_nine_error_to_io(e: AsyncNineError) -> io::Error for error conversion. Critical: readdir bridge must re-encode Vec<AsyncDirEntry>Rreaddir wire bytes using DirEntry::encode() + BytesMut (see design section). write bridge must convert Vec<u8>Bytes via Bytes::from(). Implement set_msize and reset as no-ops (not exposed to callback). Excluded methods (auth, remove, flush, mknod, lock, getlock, xattrwalk, xattrcreate, rename) use default trait impls returning ENOTSUP. crates/ninep-uniffi-async/src/callback.rs DONE
E3-T4 TEST Unit test for callback bridge round-trip: create a Rust struct implementing AsyncFilesystemCallback, wrap in AsyncFilesystemBridge, call attach/walk/read/readdir and verify type conversion correctness. Include dedicated readdir re-encoding test: callback returns Vec<AsyncDirEntry>, bridge re-encodes to Rreaddir wire bytes, verify by decoding the bytes back with DirEntry::decode() and comparing. crates/ninep-uniffi-async/src/callback.rs or tests/ DONE
E3-T5 TEST Verify cargo build -p ninep-uniffi-async succeeds. Run cargo test. DONE

Acceptance Criteria:

  • AsyncNineServerWrapper compiles with TCP and Unix serve methods
  • AsyncFilesystemCallback declares all 18 methods as callback interface
  • AsyncFilesystemBridge correctly converts all FFI types to/from proto types
  • Bridge round-trip test passes for attach, walk, and read operations

Epic 4: Swift Binding Generation & Validation

Goal: Generate Swift bindings and verify they compile. Run all Rust tests including integration test.

Prerequisites: Epics 2 and 3 complete.

Status: DONE

Task ID Type Description Files Status
E4-T1 IMPL Run cargo build -p ninep-uniffi-async to produce libninep_uniffi_async.dylib. DONE
E4-T2 IMPL Run cargo run -p ninep-uniffi-async --bin uniffi-bindgen -- generate --library target/debug/libninep_uniffi_async.dylib --language swift --out-dir target/uniffi-swift to generate Swift bindings. target/uniffi-swift/ DONE
E4-T3 TEST Verify generated Swift file exists and compiles: swiftc -parse target/uniffi-swift/ninep_uniffi_async.swift -import-objc-header target/uniffi-swift/ninep_uniffi_asyncFFI.h (syntax check only). DONE
E4-T4 TEST Rust integration test: start an AsyncNineServer with AsyncHostFs (via direct Rust, not FFI), connect an AsyncNineP9Client (through FFI wrapper), perform walk + read + write cycle, verify data round-trips correctly. Add as crates/ninep-uniffi-async/tests/integration.rs. crates/ninep-uniffi-async/tests/integration.rs DONE
E4-T5 TEST Run full test suite: cargo test -p ninep-uniffi-async. Verify all unit and integration tests pass. DONE

Acceptance Criteria:

  • uniffi-bindgen generate produces ninep_uniffi_async.swift, ninep_uniffi_asyncFFI.h, ninep_uniffi_asyncFFI.modulemap
  • Generated Swift bindings compile without errors
  • Integration test passes: client connects to server, walks, reads, writes through FFI wrapper
  • All cargo test tests pass

References

  1. ninep-uniffi-async-integration.planner-guide.md — Primary design reference (architecture, API surfaces, type mappings, error handling, Xcode integration, phases, Appendix B decisions)
  2. crates/ninep-uniffi/src/lib.rs — Existing sync FFI crate (pattern reference)
  3. UniFFI Manual — Async/Futures — Async function export documentation
  4. UniFFI Manual — Callback Interfaces — Foreign-implemented traits
  5. crates/ninep-client/src/async_client.rsAsyncNineClient<T> API surface
  6. crates/ninep-server/src/handler.rsAsyncFilesystem trait (all ~25 methods)
  7. crates/ninep-server/src/async_server.rsAsyncNineServer accept loop pattern
  8. crates/ninep-server/tests/integration_async.rs — Existing async client↔server test patterns