# sftp-rs: Asynchronous Client (`async.rs`) ## `AsyncSftpClient` An async SFTP client that supports **concurrent pipelined requests** over a single connection via a background reader task: ```rust pub struct AsyncSftpClient { writer: TokioMutex, pending: Pending, last_request_id: AtomicU32, version: u32, extensions: Vec<(String, String)>, reader_task: TokioMutex>>, } ``` Where: ```rust type Pending = Arc)>>>>; ``` ## Architecture: Background Reader + Oneshot Channels Unlike the sync client (which does send-then-receive per request), the async client decouples writing from reading: 1. **Writer side**: Each call to `process()` writes a request packet (with a unique request-id) to the `writer`, protected by a `TokioMutex` 2. **Reader side**: A spawned tokio task (`run_reader`) continuously reads packets from the reader half, strips the request-id from each response, and routes it to the matching `oneshot::Sender` in the `pending` map 3. **Caller**: Awaits on the `oneshot::Receiver`, which resolves when the reader task delivers the matching response This allows multiple requests to be in flight simultaneously — the client can send requests 1, 2, and 3, and the reader will route each response to the correct waiter regardless of arrival order. ``` ┌─────────────────┐ write ┌──────────────┐ │ calling task │──────────────→│ writer (W) │ │ (await rx) │ └──────────────┘ └────────┬────────┘ │ oneshot channel │ (tx inserted into pending map) │ ┌────────┴────────┐ read ┌──────────────┐ │ reader task │←──────────────│ reader (R) │ │ (run_reader) │ └──────────────┘ │ │ │ 1. read packet │ │ 2. split req_id│ │ 3. lookup pending[req_id] │ 4. send via tx │ └─────────────────┘ ``` ## Construction ```rust impl AsyncSftpClient { pub async fn new(mut reader: R, mut writer: W) -> std::io::Result where R: AsyncRead + Unpin + Send + 'static, } ``` The constructor: 1. Sends `SSH_FXP_INIT` with version 3 2. Reads the response, expects `SSH_FXP_VERSION` 3. Parses version and extensions 4. Spawns the background reader task (`run_reader`) 5. Returns the client The reader and writer are provided as separate halves — typically obtained via `tokio::io::split()` on a duplex stream. ## Drop Implementation ```rust impl Drop for AsyncSftpClient { fn drop(&mut self) { if let Ok(mut guard) = self.reader_task.try_lock() { if let Some(handle) = guard.take() { handle.abort(); } } } } ``` When the client is dropped, the background reader task is aborted. This prevents the task from running after the client's channels are gone. The `try_lock()` avoids blocking in the drop handler. ## Request-Response Cycle: `process()` ```rust async fn process(&self, cmd: u8, body: &[u8]) -> std::io::Result<(u8, Vec)> ``` 1. Allocate `request_id` via `AtomicU32::fetch_add(1, SeqCst)` 2. Create a `oneshot::channel()` 3. Insert `tx` into `pending[request_id]` 4. Prepend request-id: `with_request_id(request_id, body)` 5. Lock `writer` and send the packet via `write_packet_async` 6. If the write fails, remove the pending entry and return the error 7. Await on `rx` — resolves with `(cmd, payload)` when the reader task delivers the response ## Background Reader: `run_reader()` ```rust async fn run_reader(mut reader: R, pending: Pending) ``` Runs in a loop: 1. Read a packet via `read_packet_async` 2. Split the request-id from the body 3. Look up `pending[request_id]` and remove it 4. Send `(cmd, payload)` via the oneshot channel 5. If the read fails (EOF, connection error), clear the entire pending map so all waiting tasks get a `RecvError` and return errors ## Async Packet I/O ```rust async fn read_packet_async(r: &mut R) -> std::io::Result<(u8, Vec)> async fn write_packet_async(w: &mut W, kind: u8, body: &[u8]) -> std::io::Result<()> ``` These mirror the sync `read_raw_packet` / `write_raw_packet` but use `AsyncReadExt` / `AsyncWriteExt`. The write function builds the header inline: ```rust let mut hdr = Vec::with_capacity(5); hdr.extend_from_slice(&(body.len() as u32 + 1).to_be_bytes()); // length (includes type byte) hdr.push(kind); // type w.write_all(&hdr).await?; w.write_all(body).await?; w.flush().await?; ``` ## Public API The async client exposes the same operations as the sync client, but all methods are `async`: ```rust // Directory operations pub async fn mkdir(&self, path: &str, attr: &Attributes) -> Result<()> pub async fn rmdir(&self, path: &str) -> Result<()> pub async fn opendir(&self, path: &str) -> Result pub async fn readdir(&self, dir: &Directory) -> Result> pub async fn closedir(&self, dir: &Directory) -> Result<()> // File operations pub async fn open(&self, path: &str, options: OpenOptions, attr: &Attributes) -> Result pub async fn pread(&self, file: &File, offset: u64, length: u32) -> Result> pub async fn pwrite(&self, file: &File, offset: u64, data: &[u8]) -> Result<()> pub async fn fclose(&self, file: &File) -> Result<()> // Attribute operations pub async fn stat(&self, path: &str, flags: Option) -> Result pub async fn lstat(&self, path: &str, flags: Option) -> Result pub async fn fstat(&self, file: &File, flags: Option) -> Result pub async fn setstat(&self, path: &str, attr: &Attributes) -> Result<()> pub async fn fsetstat(&self, file: &File, attr: &Attributes) -> Result<()> // Path operations pub async fn realpath(&self, path: &str, control_byte: Option, compose_path: Option<&str>) -> Result pub async fn readlink(&self, path: &str) -> Result pub async fn remove(&self, path: &str) -> Result<()> pub async fn rename(&self, oldpath: &str, newpath: &str, flags: Option) -> Result<()> // Link operations pub async fn symlink(&self, path: &str, target: &str) -> Result<()> pub async fn hardlink(&self, path: &str, target: &str) -> Result<()> pub async fn link(&self, path: &str, target: &str, symlink: bool) -> Result<()> // Lock operations pub async fn block(&self, file: &File, offset: u64, length: u64, lockmask: u32) -> Result<()> pub async fn unblock(&self, file: &File, offset: u64, length: u64) -> Result<()> // Extended operations pub async fn extended(&self, request: &str, data: &[u8]) -> Result>> pub async fn flineseek(&self, file: &File, lineno: u64) -> Result<()> // Introspection pub fn extensions(&self) -> &[(String, String)] pub fn version(&self) -> u32 ``` ## Concurrency Benefits Because the reader task decouples receiving from sending, multiple async operations can run concurrently: ```rust // Three concurrent mkdir requests — all three are sent before any // response arrives, and the reader task routes each response correctly let (r1, r2, r3) = tokio::join!( client.mkdir("/a", &attrs), client.mkdir("/b", &attrs), client.rmdir("/c"), ); ``` The sync client cannot do this — each `process()` call blocks on its response before the next request can be sent. ## Error Propagation on Disconnect When the reader task encounters a read error (connection closed), it: 1. Clears the entire `pending` map 2. All `oneshot::Receiver`s in waiting tasks receive `Err(RecvError)` 3. The `process()` method converts this to `std::io::Error("reader task closed before response arrived")` This ensures that pending operations fail promptly rather than hanging indefinitely when the connection drops. ## Pending Map: `StdMutex` vs `TokioMutex` The `pending` map uses `std::sync::Mutex` rather than `tokio::sync::Mutex` because: - The critical section is tiny (insert/remove from a HashMap) - The reader task and writer are on different async tasks but need shared access - `StdMutex` avoids holding a lock across `.await` points (the oneshot `rx.await` is outside the lock)