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
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.
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.
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.
The ninep-uniffi crate (1063 lines, crates/ninep-uniffi/src/lib.rs) establishes all conventions:
uniffi::setup_scaffolding!()macro for proc-macro mode (no.udlfile)#[derive(uniffi::Error)]+thiserror::Errorfor FFI error types#[derive(uniffi::Record)]for data transfer types (Qid, FileStat, etc.)#[derive(uniffi::Object)]+#[uniffi::export]for the client objectFromimpls for converting between proto types and FFI typessrc/bin/uniffi-bindgen.rs— standard UniFFI bindgen binary (uniffi::uniffi_bindgen_main())Mutex<SyncNineClient>for interior mutability behindArc- Constructor uses
P9_MIN_MSIZE(4096) for version negotiation
-
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.
-
AsyncNineClienthas 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. -
Server FFI is missing. For testing and future use, Swift needs to be able to implement a 9P server via callback interfaces.
-
Type namespace collision risk. Putting async types in the existing
ninep-unifficrate would force shared type names and couple the sync/async evolution paths.
- New crate
ninep-uniffi-async— standalonecdylib+libcrate producinglibninep_uniffi_asyncand generatingninep_uniffi_async.swiftbindings. - 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. - Server wrapper (
AsyncNineServerWrapper) — exposesAsyncFilesystemCallbackas a UniFFI callback interface withAsyncFilesystemBridgeadapter. - All FFI record types —
AsyncQid,AsyncFileStat,AsyncDirEntry,AsyncSetAttrInput,AsyncFsStats,AsyncOpenResult,AsyncCreateResult, plus server response records (AsyncRattach,AsyncRwalk,AsyncRlopen,AsyncRlcreate). AsyncNineError— rich error enum with errno preservation and tracing.- Swift binding generation —
uniffi-bindgen generateproducing.swift,.h,.modulemapfiles. - Validation — Rust unit tests (smart wrapper, callback bridge), Swift compilation check, Rust integration test (async client ↔ async server through FFI wrappers).
- 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::Mutexis acceptable for PoC. - Shared runtime — per-object runtime is correct for single-client-per-volume use case.
- Sync server FFI —
NineServer+Filesystemcallback in the existingninep-unifficrate (guide Phase 3) is out of scope for this plan.
| 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 |
| 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 | Bytes → Vec<u8> conversion at FFI boundary (one copy per operation, acceptable for PoC) |
┌────────────────────────────────────────────────────────────────┐
│ 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 │
└────────────────────────────────────────────────────────────────┘
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.
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:
- Build a
tokio::runtime::Runtime(multi-thread, default thread count) - Connect the transport (TCP or Unix)
- Perform
Tversionnegotiation (storesmsize) - Perform
Tattachwith root FID 0 (stores root Qid, tracks root FID) - 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.
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() })?
}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 loopserve_unix(path: String, fs: Arc<dyn AsyncFilesystemCallback>)— binds Unix socketshutdown()— 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.
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).
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.
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.
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
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
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 |
| 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) |
| 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 |
- 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.tomlmust be updated to includeninep-uniffi-asyncinmembers.
| 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. |
Bytes → Vec<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. |
-
Constructor async bootstrap blocking: UniFFI constructors return
Arc<Self>synchronously. The constructor usesruntime.block_on()for version+attach. This briefly blocks the calling Swift thread. Verify this is acceptable for FSKit'sactivate()context. If not, consider a two-phase init pattern:new()→connect()async method. -
walkreturn type for low-level API: The guide sayswalk(parent_fid, names) → (u32, Vec<AsyncQid>). UniFFI doesn't support tuple returns natively. Resolution: Return anAsyncWalkResult { fid: u32, qids: Vec<AsyncQid> }record instead. -
staticlibvscdylib: The guide notes FSKit extensions are app extensions. Sandboxed contexts may have trouble loading dynamic libraries. The current plan usescdylib(matching the sync crate). If linking fails, switch tostaticlib.
Exit criteria: cargo build -p ninep-uniffi-async succeeds. All record types, error enum, and conversion helpers compile. No exported methods yet.
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.
Exit criteria: AsyncNineServerWrapper, AsyncFilesystemCallback, and AsyncFilesystemBridge compile. Unit test verifying callback bridge round-trip (Rust struct implements callback interface, calls bridge, verifies type conversions).
Exit criteria: uniffi-bindgen generate produces valid .swift, .h, .modulemap. Swift bindings compile with swiftc.
Exit criteria: Rust integration test passes — async client connects to async server through FFI wrapper, performs walk + read + write cycle. All unit tests pass.
| 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 |
| File Path | Changes |
|---|---|
Cargo.toml (workspace root) |
Add "crates/ninep-uniffi-async" to workspace.members |
None.
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-asynccompiles without errors - All FFI record types have bidirectional
Fromimpls tested -
AsyncNineErrorconversion fromClientErrortested for all variants -
AnyAsyncTransportdispatches to both TCP and Unix transports
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:
-
AsyncNineP9Clientcompiles with all methods exported -
parse_pathcorrectly 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
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:
-
AsyncNineServerWrappercompiles with TCP and Unix serve methods -
AsyncFilesystemCallbackdeclares all 18 methods as callback interface -
AsyncFilesystemBridgecorrectly converts all FFI types to/from proto types - Bridge round-trip test passes for attach, walk, and read operations
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 generateproducesninep_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 testtests pass
ninep-uniffi-async-integration.planner-guide.md— Primary design reference (architecture, API surfaces, type mappings, error handling, Xcode integration, phases, Appendix B decisions)crates/ninep-uniffi/src/lib.rs— Existing sync FFI crate (pattern reference)- UniFFI Manual — Async/Futures — Async function export documentation
- UniFFI Manual — Callback Interfaces — Foreign-implemented traits
crates/ninep-client/src/async_client.rs—AsyncNineClient<T>API surfacecrates/ninep-server/src/handler.rs—AsyncFilesystemtrait (all ~25 methods)crates/ninep-server/src/async_server.rs—AsyncNineServeraccept loop patterncrates/ninep-server/tests/integration_async.rs— Existing async client↔server test patterns