sansio 1.0.1

sansio — an architectural pattern for writing protocol implementations that are completely decoupled from I/O operations
Documentation

What is Sans-IO?

Sans-IO (French for "without I/O") is an architectural pattern for writing protocol implementations that are completely decoupled from I/O operations. This makes protocols:

  • Testable: Test protocol logic without mocking sockets or async runtimes
  • Portable: Run the same protocol code in sync, async, embedded, or WASM environments
  • Composable: Stack and combine protocol layers easily
  • Debuggable: Step through protocol state machines without I/O side effects

Features

  • Clean push-pull API for message handling
  • Zero-cost abstractions with generic type parameters
  • Support for custom events and timeout handling
  • Works with any I/O backend (sync, async, embedded)
  • No dependencies, minimal footprint
  • Fully documented with examples
  • no_std by default - works in any environment

no_std Support

This crate is no_std by default and works seamlessly in any environment - embedded systems, bare-metal applications, WASM, or standard applications.

Time Handling

The Time associated type is fully generic, so you can use any time representation that fits your environment:

Using std::time::Instant:

impl Protocol<Vec<u8>, Vec<u8>, ()> for MyProtocol {
    type Time = std::time::Instant;
    // ...
}

Using tick counts (embedded):

impl Protocol<Vec<u8>, Vec<u8>, ()> for MyProtocol {
    type Time = u64;  // System ticks
    // ...
}

Using milliseconds:

impl Protocol<Vec<u8>, Vec<u8>, ()> for MyProtocol {
    type Time = i64;  // Milliseconds since epoch
    // ...
}

No timeout needed:

impl Protocol<Vec<u8>, Vec<u8>, ()> for MyProtocol {
    type Time = ();  // Unit type when timeouts aren't used
    // ...
}

The Protocol Trait

The Protocol trait provides a simplified Sans-IO interface for building network protocols:

pub trait Protocol<Rin, Win, Ein> {
    type Rout;  // Output read type
    type Wout;  // Output write type
    type Eout;  // Output event type
    type Error; // Error type
    type Time;  // Time type (u64, Instant, i64, (), etc.)

    // Push data into protocol
    fn handle_read(&mut self, msg: Rin) -> Result<(), Self::Error>;
    fn handle_write(&mut self, msg: Win) -> Result<(), Self::Error>;
    fn handle_event(&mut self, evt: Ein) -> Result<(), Self::Error>;
    fn handle_timeout(&mut self, now: Self::Time) -> Result<(), Self::Error>;

    // Pull results from protocol
    fn poll_read(&mut self) -> Option<Self::Rout>;
    fn poll_write(&mut self) -> Option<Self::Wout>;
    fn poll_event(&mut self) -> Option<Self::Eout>;
    fn poll_timeout(&mut self) -> Option<Self::Time>;

    // Lifecycle
    fn close(&mut self) -> Result<(), Self::Error>;
}

Quick Start

Add sansio to your Cargo.toml:

[dependencies]
sansio = "1"

Example: Uppercase Protocol

A simple protocol that converts incoming strings to uppercase:

use sansio::Protocol;
use std::collections::VecDeque;

#[derive(Debug)]
struct MyError;
impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "MyError")
    }
}
impl std::error::Error for MyError {}

/// A simple uppercase protocol: converts incoming strings to uppercase
struct UppercaseProtocol {
    routs: VecDeque<String>,
    wouts: VecDeque<String>,
}

impl UppercaseProtocol {
    fn new() -> Self {
        Self {
            routs: VecDeque::new(),
            wouts: VecDeque::new(),
        }
    }
}

impl Protocol<String, String, ()> for UppercaseProtocol {
    type Rout = String;
    type Wout = String;
    type Eout = ();
    type Error = MyError;
    type Time = ();  // No timeout handling needed

    fn handle_read(&mut self, msg: String) -> Result<(), Self::Error> {
        // Process incoming message
        self.routs.push_back(msg.to_uppercase());
        Ok(())
    }

    fn poll_read(&mut self) -> Option<Self::Rout> {
        // Return processed message
        self.routs.pop_front()
    }

    fn handle_write(&mut self, msg: String) -> Result<(), Self::Error> {
        // For this simple protocol, just pass through
        self.wouts.push_back(msg);
        Ok(())
    }

    fn poll_write(&mut self) -> Option<Self::Wout> {
        self.wouts.pop_front()
    }
}

// Usage example
fn main() {
    let mut protocol = UppercaseProtocol::new();

    // Push data in
    protocol.handle_read("hello world".to_string()).unwrap();

    // Pull results out
    assert_eq!(protocol.poll_read(), Some("HELLO WORLD".to_string()));
}

Testing Made Easy

Sans-IO protocols are trivial to test because they don't involve any I/O:

#[test]
fn test_uppercase_protocol() {
    let mut protocol = UppercaseProtocol::new();

    // Test single message
    protocol.handle_read("test".to_string()).unwrap();
    assert_eq!(protocol.poll_read(), Some("TEST".to_string()));

    // Test multiple messages
    protocol.handle_read("hello".to_string()).unwrap();
    protocol.handle_read("world".to_string()).unwrap();
    assert_eq!(protocol.poll_read(), Some("HELLO".to_string()));
    assert_eq!(protocol.poll_read(), Some("WORLD".to_string()));
    assert_eq!(protocol.poll_read(), None);
}

Use Cases

  • Network Protocols: HTTP, WebSocket, custom TCP/UDP protocols
  • Message Parsers: Protocol buffers, JSON-RPC, custom formats
  • State Machines: Connection handling, handshakes, negotiations
  • Protocol Testing: Unit test protocol logic without network I/O
  • Embedded Systems: Protocols that need to work with both blocking and non-blocking I/O
  • WASM: Browser-based protocol implementations

Why Sans-IO?

Traditional protocol implementations mix I/O and protocol logic:

// Traditional approach - tightly coupled to async I/O
async fn handle_connection(mut stream: TcpStream) {
    let mut buf = [0u8; 1024];
    while let Ok(n) = stream.read(&mut buf).await {
        // Protocol logic mixed with I/O
        let response = process(&buf[..n]);
        stream.write_all(&response).await.unwrap();
    }
}

Sans-IO separates concerns:

// Sans-IO approach - protocol logic is independent
struct MyProtocol {
    /* ... */
}
impl Protocol<Vec<u8>, Vec<u8>, ()> for MyProtocol { /* ... */ }

// I/O layer is separate and can be swapped
async fn handle_connection(mut stream: TcpStream, mut protocol: MyProtocol) {
    let mut buf = [0u8; 1024];
    while let Ok(n) = stream.read(&mut buf).await {
        protocol.handle_read(buf[..n].to_vec()).unwrap();
        while let Some(response) = protocol.poll_write() {
            stream.write_all(&response).await.unwrap();
        }
    }
}

Benefits:

  • Protocol logic can be tested without async runtime
  • Same protocol works with sync, async, or embedded I/O
  • Easier to debug and reason about
  • Protocol layers can be composed and reused

Documentation

Full API documentation is available at docs.rs/sansio

License

Licensed under either of:

at your option.