Client runtime

Version 0.3.0 Updated Apr 06, 2026

WireframeClient provides a first-class client runtime that mirrors the server's framing and serialization layers, with a builder that configures the serializer, codec settings, and socket options before connecting.[^44] Use ClientCodecConfig to align max_frame_length with the server's buffer_capacity, and apply SocketOptions when TCP tuning is required, such as TCP_NODELAY or buffer size adjustments.

Client configuration reference

Surface API Default Use when
Serializer WireframeClient::builder().serializer(...) BincodeSerializer Server and client need a non-default serialization contract.
Frame codec settings codec_config(ClientCodecConfig) 4-byte big-endian prefix and 1024-byte max frame length Server buffer_capacity or protocol limits differ from defaults.
Socket tuning socket_options(SocketOptions) OS defaults Low-latency tuning (TCP_NODELAY) or keepalive policy is required.
Preamble payload with_preamble(T) Disabled Protocol negotiation must happen before framed payload traffic.
Preamble timeout preamble_timeout(Duration) Disabled unless configured Connection setup should fail fast on stalled preamble exchange.
Setup hook on_connection_setup(...) Disabled Per-connection state is needed for metrics or lifecycle coupling.
Teardown hook on_connection_teardown(...) Disabled Close should flush counters or release stateful resources.
Error hook on_error(...) Disabled Transport and decode failures must be routed to observability.
Before-send hook before_send(...) Disabled Inspect or mutate serialized bytes before every outgoing frame.
After-receive hook after_receive(...) Disabled Inspect or mutate raw bytes after every incoming frame is read.
Tracing config tracing_config(TracingConfig) INFO connect/close, DEBUG data ops, timing off Customize tracing span levels and per-command timing.
Pool connect connect_pool(addr, ClientPoolConfig) Disabled Warm socket reuse or bounded socket fan-out is required.
Pool size ClientPoolConfig::pool_size(n) 4 More than one warm physical socket should be maintained.
Per-socket admission max_in_flight_per_socket(n) 1 More than one caller may queue work against the same warm socket.
Idle recycle idle_timeout(Duration) 600s Idle sockets should be replaced before they become stale.
use std::{net::SocketAddr, time::Duration};

use wireframe::{
    app::Envelope,
    client::{ClientCodecConfig, SocketOptions},
    correlation::CorrelatableFrame,
    message::Message,
    WireframeClient,
};

#[derive(bincode::Encode, bincode::BorrowDecode)]
struct Login {
    username: String,
}

#[derive(bincode::Encode, bincode::BorrowDecode, Debug, PartialEq)]
struct LoginAck {
    username: String,
}

let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let codec = ClientCodecConfig::default().max_frame_length(2048);
let socket = SocketOptions::default()
    .nodelay(true)
    .keepalive(Some(Duration::from_secs(30)));

let mut client = WireframeClient::builder()
    .codec_config(codec)
    .socket_options(socket)
    .connect(addr)
    .await?;

let login = Login {
    username: "guest".to_string(),
};
let request = Envelope::new(1, None, login.to_bytes()?);
let response: Envelope = client.call_correlated(request).await?;
let (ack, _) = LoginAck::from_bytes(response.payload_bytes())?;
assert_eq!(ack.username, "guest");
assert!(response.correlation_id().is_some());

For screen readers: the following sequence diagram shows the client lifecycle for connect, optional preamble exchange, message I/O, error handling, and teardown.

Client hooksTcpStreamWireframeClientBuilderApplicationClient hooksTcpStreamWireframeClientBuilderApplicationalt[preamble configured]connect(addr)apply socket options + connectwrite/read preamblepreamble success/failure callbackson_connection_setupsend / receive / call / call_correlatedon_error (if operation fails)close()on_connection_teardown

Run the client example against the echo server:

# terminal 1
cargo run --example echo --features examples

# terminal 2
cargo run --example client_echo_login --features examples

Client pools

Use connect_pool when one warm socket is too little, but opening a new TCP connection for every request is too expensive. WireframeClientPool keeps a bounded set of warm sockets, preserves preamble state on reuse, recycles idle sockets after the configured timeout, and can schedule blocked logical sessions fairly through PoolHandle.

use std::{net::SocketAddr, time::Duration};

use wireframe::client::{ClientPoolConfig, PoolFairnessPolicy, WireframeClient};

#[derive(bincode::Encode, bincode::Decode)]
struct Ping(u8);

#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq)]
struct Pong(u8);

# #[tokio::main]
# async fn main() -> Result<(), wireframe::client::ClientError> {
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let pool = WireframeClient::builder()
    .connect_pool(
        addr,
        ClientPoolConfig::default()
            .pool_size(2)
            .max_in_flight_per_socket(2)
            .idle_timeout(Duration::from_secs(30))
            .fairness_policy(PoolFairnessPolicy::RoundRobin),
    )
    .await?;

let lease = pool.acquire().await?;
let pong: Pong = lease.call(&Ping(1)).await?;
assert_eq!(pong, Pong(1));

pool.close().await;
# Ok(())
# }

Pooled leases forward the common request methods instead of exposing a mutable reference to a long-lived WireframeClient. That keeps actual socket I/O serialized per physical connection while still allowing multiple callers to be admitted against the same warm slot. Treat max_in_flight_per_socket as an admission budget, not as a guarantee of parallel writes on one TCP stream.

Create a PoolHandle when one logical session needs repeated pooled access and that session should participate in the configured fairness policy over time:

# use std::net::SocketAddr;
use wireframe::client::{ClientPoolConfig, PoolFairnessPolicy, PoolHandle, WireframeClient};

#[derive(bincode::Encode, bincode::Decode)]
struct Ping(u8);

#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq)]
struct Pong(u8);

# #[tokio::main]
# async fn main() -> Result<(), wireframe::client::ClientError> {
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let pool = WireframeClient::builder()
    .connect_pool(
        addr,
        ClientPoolConfig::default()
            .pool_size(1)
            .fairness_policy(PoolFairnessPolicy::RoundRobin),
    )
    .await?;

let mut session_a: PoolHandle<_, _, _> = pool.handle();
let mut session_b = pool.handle();

let pong_a: Pong = session_a.call(&Ping(1)).await?;
let pong_b: Pong = session_b.call(&Ping(2)).await?;

assert_eq!(pong_a, Pong(1));
assert_eq!(pong_b, Pong(2));
# Ok(())
# }

Use pool.handle() instead of repeated pool.acquire() when a long-lived workflow should receive fair turns relative to other workflows. Fairness policies order blocked handles; they do not create a second queue outside the pool or bypass its back-pressure. If all permits are busy, the waiting handle still blocks until capacity returns.

Continue using PooledClientLease for explicit split-phase work such as send() followed later by receive(). PoolHandle does not pin a logical session to one socket and does not demultiplex arbitrary responses across shared handles.

Preamble callbacks and setup hooks run per physical socket creation. Warm reuse keeps the existing preamble state; idle recycle closes the old socket and creates a fresh one, which reruns preamble and setup.

Client preamble exchange

The client builder supports an optional preamble exchange before framing begins. Use with_preamble to send a preamble immediately after TCP connect, and register callbacks for success or failure scenarios.[^47]

use std::{net::SocketAddr, time::Duration};

use futures::FutureExt;
use wireframe::{
    preamble::read_preamble,
    WireframeClient,
};

#[derive(bincode::Encode, bincode::BorrowDecode)]
struct ClientHello {
    version: u16,
}

#[derive(bincode::BorrowDecode)]
struct ServerAck {
    accepted: bool,
}

let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");

let client = WireframeClient::builder()
    .with_preamble(ClientHello { version: 1 })
    .preamble_timeout(Duration::from_secs(5))
    .on_preamble_success(|_preamble, stream| {
        async move {
            // Read server acknowledgement
            let (ack, leftover) = read_preamble::<_, ServerAck>(stream)
                .await
                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
            if !ack.accepted {
                return Err(std::io::Error::new(
                    std::io::ErrorKind::ConnectionRefused,
                    "server rejected preamble",
                ));
            }
            Ok(leftover) // Return any leftover bytes for framing layer
        }
        .boxed()
    })
    .on_preamble_failure(|err, _stream| {
        async move {
            eprintln!("Preamble exchange failed: {err}");
            Ok(())
        }
        .boxed()
    })
    .connect(addr)
    .await?;

The success callback receives the sent preamble and a mutable reference to the TCP stream, enabling bidirectional preamble negotiation. Any bytes read beyond the server's response must be returned as "leftover" bytes so they can be replayed before the framing layer begins. The failure callback runs when the preamble exchange fails (timeout, I/O error, or encode error) and can log diagnostics or send an error response before the connection closes. In the current implementation, callback read failures surface as ClientError::PreambleRead, timeout expiry surfaces as ClientError::PreambleTimeout, and write-side failures from write_preamble(...) are wrapped by ClientError::PreambleEncode because the helper returns bincode::EncodeError.

Client lifecycle hooks

The client builder supports lifecycle hooks that mirror the server's hook system, enabling consistent instrumentation across both client and server.[^48]

  • Setup hook (on_connection_setup): Invoked once after the TCP connection is established and preamble exchange (if configured) succeeds. Returns connection-specific state stored for the connection's lifetime.
  • Teardown hook (on_connection_teardown): Invoked when close() is called. Receives the state produced by the setup hook for cleanup.
  • Error hook (on_error): Invoked when errors occur during send, receive, or call operations. Receives a reference to the error for logging or metrics.
use std::{net::SocketAddr, sync::Arc};
use std::sync::atomic::{AtomicUsize, Ordering};

use wireframe::client::WireframeClient;

struct SessionState {
    request_count: AtomicUsize,
}

let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");

let client = WireframeClient::builder()
    .on_connection_setup(|| async {
        SessionState {
            request_count: AtomicUsize::new(0),
        }
    })
    .on_connection_teardown(|state: SessionState| async move {
        println!(
            "Session ended after {} requests",
            state.request_count.load(Ordering::SeqCst)
        );
    })
    .on_error(|err| async move {
        eprintln!("Client error: {err}");
    })
    .connect(addr)
    .await?;

// Use the client...

client.close().await; // Teardown hook invoked here

The setup hook runs after connection establishment (and preamble exchange if configured). The teardown hook only runs if a setup hook was configured and successfully produced state. The error hook is independent and can be configured without a setup hook.

Client request hooks

The client builder supports request hooks that fire on every outgoing and incoming frame, enabling symmetric instrumentation with the server middleware stack.[^52]

  • Before-send hook (before_send): Invoked after serialization, before each frame is written to the transport. Receives a &mut Vec<u8> of the serialized bytes, allowing inspection or mutation (e.g., prepending an authentication token, incrementing a metrics counter).
  • After-receive hook (after_receive): Invoked after each frame is read from the transport, before deserialization. Receives a &mut BytesMut of the raw frame bytes, allowing inspection or mutation before the deserializer processes them.

Multiple hooks of the same kind may be registered; they execute in registration order. Hooks are synchronous Fn closures—users who need mutable state should capture an Arc<AtomicUsize> or Arc<Mutex<_>> in the closure.

use std::{net::SocketAddr, sync::Arc};
use std::sync::atomic::{AtomicUsize, Ordering};

use wireframe::client::WireframeClient;

let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");

let send_counter = Arc::new(AtomicUsize::new(0));
let recv_counter = Arc::new(AtomicUsize::new(0));
let sc = send_counter.clone();
let rc = recv_counter.clone();

let mut client = WireframeClient::builder()
    .before_send(move |_bytes: &mut Vec<u8>| {
        sc.fetch_add(1, Ordering::SeqCst);
    })
    .after_receive(move |_bytes: &mut bytes::BytesMut| {
        rc.fetch_add(1, Ordering::SeqCst);
    })
    .connect(addr)
    .await?;

Request hooks fire on all message paths: send, send_envelope, receive_envelope, call_correlated, call_streaming, and ResponseStream polling. Clients configured without request hooks behave identically to clients that have none registered.

Outbound streaming sends

The client supports sending large request bodies as multiple frames using send_streaming. The caller provides a protocol-defined frame header and an AsyncRead body source; the helper reads chunks, prepends the header to each chunk, and emits framed packets over the transport.

use std::net::SocketAddr;
use wireframe::client::{SendStreamingConfig, WireframeClient};

# #[tokio::main]
# async fn main() -> Result<(), wireframe::client::ClientError> {
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid address");
let mut client = WireframeClient::builder().connect(addr).await?;

let header = [0xCA, 0xFE, 0xBA, 0xBE];
let body = vec![0u8; 4096];
let config = SendStreamingConfig::default()
    .with_chunk_size(512)
    .with_timeout(std::time::Duration::from_secs(5));

let outcome = client.send_streaming(&header, &body[..], config).await?;
println!("sent {} frames", outcome.frames_sent());
# Ok(())
# }

SendStreamingConfig controls chunking and timeout behaviour:

  • with_chunk_size(usize) — maximum body bytes per frame. When not set, the chunk size is derived as max_frame_length - header.len().
  • with_timeout(Duration) — timeout for the entire operation. If the timeout elapses, std::io::ErrorKind::TimedOut is returned and no further frames are emitted. Any frames already sent remain sent; callers must assume the operation may have been partially successful.

SendStreamingOutcome reports the number of frames emitted via frames_sent(). The error hook is invoked on all failure paths, consistent with other client send methods.[^53]

Client tracing

The client emits tracing spans around every operation. Span levels and per-command timing are configurable via TracingConfig, which is passed to the builder with tracing_config(). When no tracing subscriber is installed, all instrumentation is zero-cost.

Default span levels: INFO for lifecycle operations (connect, close) and DEBUG for data operations (send, receive, call, call_streaming). Per-command timing is disabled by default.

use std::net::SocketAddr;

use tracing::Level;
use wireframe::client::{TracingConfig, WireframeClient};

let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");

// Enable timing for connect and call, set all spans to TRACE level.
let config = TracingConfig::default()
    .with_all_levels(Level::TRACE)
    .with_connect_timing(true)
    .with_call_timing(true);

let mut client = WireframeClient::builder()
    .tracing_config(config)
    .connect(addr)
    .await?;

When timing is enabled for an operation, a DEBUG-level event recording elapsed_us is emitted when the operation completes (on both success and error paths). Streaming responses emit per-frame DEBUG events with stream.frames_received and a termination event with stream.frames_total.

Clients configured without tracing_config() use the default configuration and behave identically to the pre-tracing API.

Client message API with correlation identifiers

The client provides envelope-aware messaging APIs that work with the Packet trait, supporting automatic correlation ID generation and validation. These methods complement the basic send, receive, and call methods that operate on raw Message types.

Correlation ID generation: Each client maintains an atomic counter for generating unique correlation IDs. The next_correlation_id method returns the next ID, which is useful when managing correlation manually.

Envelope-aware methods:

  • send_envelope<P: Packet>(envelope: P) — Sends an envelope, auto-generating a correlation ID if not present. Returns the correlation ID used.
  • receive_envelope<P: Packet>() — Receives and deserializes the next frame as the specified packet type.
  • call_correlated<P: Packet>(request: P) — Sends a request with auto- generated correlation ID, receives the response, and validates that the response's correlation ID matches the request. Returns ClientError::CorrelationMismatch if the IDs differ.
use std::net::SocketAddr;

use wireframe::{
    app::{Envelope, Packet},
    client::{ClientError, WireframeClient},
};

# async fn example() -> Result<(), ClientError> {
let addr: SocketAddr = "127.0.0.1:7878".parse().expect("valid socket address");
let mut client = WireframeClient::builder()
    .connect(addr)
    .await?;

// Create an envelope without a correlation ID.
let request = Envelope::new(1, None, vec![1, 2, 3]);

// call_correlated auto-generates a correlation ID, sends the request,
// receives the response, and validates the correlation ID matches.
let response: Envelope = client.call_correlated(request).await?;

// The response's correlation ID matches the auto-generated request ID.
assert!(response.correlation_id().is_some());
# Ok(())
# }

For more control over correlation, use send_envelope and receive_envelope separately:

use wireframe::app::{Envelope, Packet};
# use wireframe::client::{ClientError, WireframeClient};
# async fn example(client: &mut WireframeClient) -> Result<(), ClientError> {

// Auto-generate correlation ID when sending.
let envelope = Envelope::new(1, None, b"payload".to_vec());
let correlation_id = client.send_envelope(envelope).await?;

// Receive the response.
let response: Envelope = client.receive_envelope().await?;

// Manually verify correlation if needed.
assert_eq!(response.correlation_id(), Some(correlation_id));
# Ok(())
# }

The CorrelationMismatch error provides diagnostic information when validation fails:

use wireframe::client::ClientError;

match client.call_correlated(request).await {
    Ok(response) => {
        // Handle successful response.
    }
    Err(ClientError::CorrelationMismatch { expected, received }) => {
        eprintln!(
            "Correlation mismatch: expected {:?}, received {:?}",
            expected, received
        );
    }
    Err(e) => {
        // Handle other errors.
    }
}

Client request/response error mapping

Client request/response operations (receive, call, receive_envelope, and call_correlated) map transport and decode failures to WireframeError variants exposed through ClientError::Wireframe:

  • Transport failures map to WireframeError::Io.
  • Decode failures map to WireframeError::Protocol with ClientProtocolError::Deserialize.
use wireframe::{
    ClientError,
    ClientProtocolError,
    WireframeError,
};

match client.call(&request).await {
    Ok(response) => {
        // Handle successful response.
    }
    Err(ClientError::Wireframe(WireframeError::Io(err))) => {
        eprintln!("transport failure: {err}");
    }
    Err(ClientError::Wireframe(WireframeError::Protocol(
        ClientProtocolError::Deserialize(err),
    ))) => {
        eprintln!("decode failure: {err}");
    }
    Err(other) => {
        // Handle serialize/preamble/correlation failures.
        eprintln!("other client error: {other}");
    }
}