Skip to content
13 changes: 13 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,19 @@ hooks, request hooks, and tracing configuration on the same path. Pooled lease
methods should go through `PooledClientLease::dispatch_on_connection` so
checkout and recycle-on-error policy stay in one place.

`ManagedClientConnection` owns its client in an `Option` and takes it once when
teardown starts. Calls through `client_mut` return a disconnection error after
that transfer. Dropping a live connection schedules `close()` on the current
Tokio runtime; without a current runtime, it builds a current-thread runtime
and runs `close()` synchronously so teardown hooks can complete.

Client timing selection has six independent categories: connect, send, receive,
call, streaming, and close. Each `TracingConfig::with_*_timing` setter changes
only its category; `with_all_timing` changes all six. Keep these selectors
independent when adding or changing client operations. Internally,
`ClientOperation` identifies a category, and `ClientTimingFlags` stores one bit
per operation.

## Error surface conventions

Library-facing errors should stay typed and inspectable by default. Use
Expand Down
61 changes: 61 additions & 0 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1637,6 +1637,67 @@ cargo run --example echo --features examples
cargo run --example client_echo_login --features examples
```

### Compile-time client configuration

Most client configuration methods are `const fn`, so configuration can be
assembled inside a `const fn` and therefore evaluated by the compiler rather
than at runtime. Types with a const constructor go further:
`SendStreamingOutcome::new` is `const`, so an outcome value can initialize a
`const` item directly, and its `frames_sent` getter can be read the same way.

The const surface covers the builder setters `codec_config`, `socket_options`,
`length_format`, and `tracing_config`; the builder socket shorthands `nodelay`,
`keepalive`, `send_buffer_size`, `recv_buffer_size`, `reuseaddr`, and
`reuseport`; `ClientCodecConfig` members such as `length_format`,
`max_frame_length_value`, and `length_format_value`; the `TracingConfig`
per-operation level and timing setters plus `with_all_levels` and
`with_all_timing`; `SendStreamingConfig::with_chunk_size` and `with_timeout`;
`ClientPoolConfig::fairness_policy`; the read-only getters
`ClientError::should_recycle_connection`, `ResponseStream::correlation_id`,
`is_terminated`, and `frame_count`; and
`SendStreamingOutcome::{new, frames_sent}`. `ClientPoolConfig` requires the
`pool` feature, which is not enabled by default. Each setter takes `self` by
value and returns `Self`, so a configuration chain works unchanged in a
constant context. Setters that clamp their input, such as
`ClientCodecConfig::max_frame_length`, `ClientPoolConfig::pool_size`, and
`ClientPoolConfig::idle_timeout`, remain ordinary functions, as does every
`Default::default` implementation. `reuseport` additionally exists only on Unix
outside Solaris, illumos, and Cygwin, so guard any call to it with the matching
`#[cfg(...)]`.

```rust
use std::time::Duration;

use wireframe::client::{SocketOptions, TracingConfig, WireframeClientBuilder};

const fn configure(
builder: WireframeClientBuilder,
socket_options: SocketOptions,
tracing: TracingConfig,
) -> WireframeClientBuilder {
builder
.socket_options(socket_options)
.tracing_config(tracing)
.nodelay(true)
.keepalive(Some(Duration::from_secs(30)))
.reuseaddr(true)
}

let builder = configure(
WireframeClientBuilder::new(),
SocketOptions::default(),
TracingConfig::default().with_connect_timing(true),
);
let _ = builder;
```

Opening a connection is not part of this surface: `connect` and `connect_pool`
are `async` methods that perform I/O, so only the builder and its configuration
values are usable in constant contexts, and the chain must be consumed by a
normal `await`ed `connect`. The public contract is enforced by the trybuild
fixture in `tests/ui/client_const_api.rs`, which compiles the same calls inside
`const fn` bodies; run `cargo test --test compile_error` to check it.

### Client pools

Use `connect_pool` when one warm socket is too little, but opening a new TCP
Expand Down
13 changes: 12 additions & 1 deletion docs/wireframe-client-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,13 @@ high-frequency data operations (send, receive, call, streaming). When no
**Per-command timing**: When enabled via `TracingConfig::with_*_timing(true)`,
an additional `tracing::debug!` event recording `elapsed_us` is emitted when
the operation completes. Timing events fire on both success and error paths.
Timing is disabled by default for all operations.
Timing is disabled by default for all operations. The six independent timing
categories are connect, send (`send`, `send_envelope`), receive (`receive`,
`receive_envelope`), call (`call`, `call_correlated`), streaming
(`call_streaming`), and close. Each per-operation setter changes only its own
category, leaving the other five unchanged. `with_all_timing` sets all six
categories together. Internally, `ClientOperation` identifies a category and
`ClientTimingFlags` stores one bit per operation.

**Design rationale — async-safe span instrumentation**: `Span::enter()` guards
must not be held across `.await` points because a multi-threaded runtime may
Expand Down Expand Up @@ -463,6 +469,11 @@ cargo run --example client_echo_login --features examples
- `PooledClientLease` forwards request methods (`send`, `receive`, `call`,
`send_envelope`, `receive_envelope`, `call_correlated`) through its slot
rather than dereferencing to a long-lived mutable `WireframeClient`.
- Each `ManagedClientConnection` owns its client in an `Option`. Teardown
takes that client exactly once, and later `client_mut` calls return a
disconnection error. Drop schedules `close()` on the current Tokio runtime;
without one, it creates a current-thread runtime and runs close
synchronously, allowing teardown hooks to run in either context.
- Rationale: this keeps socket lifecycle, reconnect, and idle recycle on
battle-tested `bb8` machinery while preserving explicit Wireframe control
over how many operations may target one warm socket.
Expand Down
4 changes: 2 additions & 2 deletions src/client/builder/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ where
/// let _ = builder;
/// ```
#[must_use]
pub fn codec_config(mut self, codec_config: ClientCodecConfig) -> Self {
pub const fn codec_config(mut self, codec_config: ClientCodecConfig) -> Self {
self.codec_config = codec_config;
self
}
Expand Down Expand Up @@ -51,7 +51,7 @@ where
/// let _ = builder;
/// ```
#[must_use]
pub fn length_format(mut self, length_format: LengthFormat) -> Self {
pub const fn length_format(mut self, length_format: LengthFormat) -> Self {
self.codec_config = self.codec_config.length_format(length_format);
self
}
Expand Down
6 changes: 5 additions & 1 deletion src/client/builder/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use crate::{
ClientError,
WireframeClient,
tracing_helpers::{connect_span, emit_timing_event},
tracing_timing::ClientOperation,
},
rewind_stream::RewindStream,
serializer::Serializer,
Expand Down Expand Up @@ -54,7 +55,10 @@ where
addr: SocketAddr,
) -> Result<WireframeClient<S, RewindStream<tokio::net::TcpStream>, C>, ClientError> {
let span = connect_span(&self.tracing_config, &addr.to_string());
let timing_start = self.tracing_config.connect_timing.then(Instant::now);
let timing_start = self
.tracing_config
.timing_enabled(ClientOperation::Connect)
.then(Instant::now);

async {
let result = self.connect_inner(addr).await;
Expand Down
16 changes: 8 additions & 8 deletions src/client/builder/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,15 @@ where
Fut: Future<Output = C2> + Send + 'static,
C2: Send + 'static,
{
// Preserve on_error since it is not parameterized by C.
// on_disconnect must be cleared because its signature depends on C.
let on_error = self.lifecycle_hooks.on_error;
// Preserve the error hook since it is not parameterized by C.
// The disconnect hook must be cleared because its signature depends on C.
let error = self.lifecycle_hooks.error;
builder_field_update!(
self,
lifecycle_hooks = LifecycleHooks {
on_connect: Some(Arc::new(move || Box::pin(f()))),
on_disconnect: None,
on_error,
connect: Some(Arc::new(move || Box::pin(f()))),
disconnect: None,
error,
}
)
}
Expand Down Expand Up @@ -95,7 +95,7 @@ where
F: Fn(C) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
self.lifecycle_hooks.on_disconnect = Some(Arc::new(move |c| Box::pin(f(c))));
self.lifecycle_hooks.disconnect = Some(Arc::new(move |c| Box::pin(f(c))));
self
}

Expand Down Expand Up @@ -124,7 +124,7 @@ where
F: for<'a> Fn(&'a ClientError) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
self.lifecycle_hooks.on_error = Some(Arc::new(move |e| Box::pin(f(e))));
self.lifecycle_hooks.error = Some(Arc::new(move |e| Box::pin(f(e))));
self
}
}
2 changes: 1 addition & 1 deletion src/client/builder/tracing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ where
/// let _ = builder;
/// ```
#[must_use]
pub fn tracing_config(mut self, config: TracingConfig) -> Self {
pub const fn tracing_config(mut self, config: TracingConfig) -> Self {
self.tracing_config = config;
self
}
Expand Down
2 changes: 1 addition & 1 deletion src/client/codec_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ impl ClientCodecConfig {
/// assert_eq!(codec.length_format_value().endianness(), Endianness::Little);
/// ```
#[must_use]
pub fn length_format(mut self, length_format: LengthFormat) -> Self {
pub const fn length_format(mut self, length_format: LengthFormat) -> Self {
self.length_format = length_format;
self
}
Expand Down
21 changes: 11 additions & 10 deletions src/client/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ impl SocketOptions {
/// assert_eq!(options, expected);
/// ```
#[must_use]
pub fn nodelay(mut self, enabled: bool) -> Self {
pub const fn nodelay(mut self, enabled: bool) -> Self {
self.nodelay = Some(enabled);
self
}
Expand All @@ -95,11 +95,12 @@ impl SocketOptions {
/// assert_eq!(options, expected);
/// ```
#[must_use]
pub fn keepalive(mut self, duration: Option<Duration>) -> Self {
self.keepalive = Some(match duration {
Some(value) => KeepAliveSetting::Duration(value),
None => KeepAliveSetting::Disabled,
});
pub const fn keepalive(mut self, duration: Option<Duration>) -> Self {
let Some(value) = duration else {
self.keepalive = Some(KeepAliveSetting::Disabled);
return self;
};
self.keepalive = Some(KeepAliveSetting::Duration(value));
self
}

Expand All @@ -115,7 +116,7 @@ impl SocketOptions {
/// assert_eq!(options, expected);
/// ```
#[must_use]
pub fn send_buffer_size(mut self, size: u32) -> Self {
pub const fn send_buffer_size(mut self, size: u32) -> Self {
self.send_buffer_size = Some(size);
self
}
Expand All @@ -132,7 +133,7 @@ impl SocketOptions {
/// assert_eq!(options, expected);
/// ```
#[must_use]
pub fn recv_buffer_size(mut self, size: u32) -> Self {
pub const fn recv_buffer_size(mut self, size: u32) -> Self {
self.recv_buffer_size = Some(size);
self
}
Expand All @@ -149,7 +150,7 @@ impl SocketOptions {
/// assert_eq!(options, expected);
/// ```
#[must_use]
pub fn reuseaddr(mut self, enabled: bool) -> Self {
pub const fn reuseaddr(mut self, enabled: bool) -> Self {
self.reuseaddr = Some(enabled);
self
}
Expand All @@ -172,7 +173,7 @@ impl SocketOptions {
not(target_os = "cygwin"),
))]
#[must_use]
pub fn reuseport(mut self, enabled: bool) -> Self {
pub const fn reuseport(mut self, enabled: bool) -> Self {
self.reuseport = Some(enabled);
self
}
Expand Down
6 changes: 3 additions & 3 deletions src/client/connect_parts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ where
.read_buffer_mut()
.reserve(initial_read_buffer_capacity);

let connection_state = if let Some(ref setup) = self.lifecycle_hooks.on_connect {
let connection_state = if let Some(ref setup) = self.lifecycle_hooks.connect {
Some(setup().await)
} else {
None
Expand All @@ -109,8 +109,8 @@ where
serializer: self.serializer,
codec_config,
connection_state,
on_disconnect: self.lifecycle_hooks.on_disconnect,
on_error: self.lifecycle_hooks.on_error,
on_disconnect: self.lifecycle_hooks.disconnect,
on_error: self.lifecycle_hooks.error,
request_hooks: self.request_hooks,
tracing_config: self.tracing_config,
correlation_counter: AtomicU64::new(1),
Expand Down
2 changes: 1 addition & 1 deletion src/client/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ impl ClientError {
/// Return `true` when this error leaves the underlying connection unsafe
/// to reuse in the pool.
#[must_use]
pub fn should_recycle_connection(&self) -> bool {
pub const fn should_recycle_connection(&self) -> bool {
match self {
Self::Wireframe(_)
| Self::PreambleWrite(_)
Expand Down
22 changes: 9 additions & 13 deletions src/client/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,35 +93,31 @@ pub type ClientErrorHandler =
/// This struct holds the optional lifecycle callbacks configured via the
/// builder. It is used internally to pass hook configuration from the builder
/// to the client runtime.
#[expect(
clippy::struct_field_names,
reason = "on_ prefix is idiomatic for callback fields"
)]
pub(crate) struct LifecycleHooks<C> {
/// Callback invoked when the connection is established.
pub(crate) on_connect: Option<ClientConnectionSetupHandler<C>>,
pub(crate) connect: Option<ClientConnectionSetupHandler<C>>,
/// Callback invoked when the connection is closed.
pub(crate) on_disconnect: Option<ClientConnectionTeardownHandler<C>>,
pub(crate) disconnect: Option<ClientConnectionTeardownHandler<C>>,
/// Callback invoked when an error occurs.
pub(crate) on_error: Option<ClientErrorHandler>,
pub(crate) error: Option<ClientErrorHandler>,
}

impl<C> Clone for LifecycleHooks<C> {
fn clone(&self) -> Self {
Self {
on_connect: self.on_connect.clone(),
on_disconnect: self.on_disconnect.clone(),
on_error: self.on_error.clone(),
connect: self.connect.clone(),
disconnect: self.disconnect.clone(),
error: self.error.clone(),
}
}
}

impl<C> Default for LifecycleHooks<C> {
fn default() -> Self {
Self {
on_connect: None,
on_disconnect: None,
on_error: None,
connect: None,
disconnect: None,
error: None,
}
}
}
Expand Down
16 changes: 13 additions & 3 deletions src/client/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use super::{
ClientError,
runtime::ClientStream,
tracing_helpers::{call_correlated_span, emit_timing_event, receive_span, send_envelope_span},
tracing_timing::ClientOperation,
};
use crate::{
app::Packet,
Expand Down Expand Up @@ -126,7 +127,10 @@ where
envelope.set_correlation_id(Some(correlation_id));
}

let timing_start = self.tracing_config.send_timing.then(Instant::now);
let timing_start = self
.tracing_config
.timing_enabled(ClientOperation::Send)
.then(Instant::now);
self.serialize_and_send(&envelope, timing_start, |config, frame_bytes| {
send_envelope_span(config, correlation_id, frame_bytes)
})
Expand Down Expand Up @@ -213,7 +217,10 @@ where
P: Packet + EncodeWith<S> + DecodeWith<S>,
{
let span = call_correlated_span(&self.tracing_config);
let timing_start = self.tracing_config.call_timing.then(Instant::now);
let timing_start = self
.tracing_config
.timing_enabled(ClientOperation::Call)
.then(Instant::now);

self.call_correlated_inner(request, &span, timing_start)
.instrument(span.clone())
Expand Down Expand Up @@ -267,7 +274,10 @@ where
/// Internal helper for receiving and deserializing a frame.
pub(crate) async fn receive_internal<R: DecodeWith<S>>(&mut self) -> Result<R, ClientError> {
let span = receive_span(&self.tracing_config);
let timing_start = self.tracing_config.receive_timing.then(Instant::now);
let timing_start = self
.tracing_config
.timing_enabled(ClientOperation::Receive)
.then(Instant::now);

self.receive_frame(&span, timing_start)
.instrument(span.clone())
Expand Down
1 change: 1 addition & 0 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ mod streaming;
mod streaming_helpers;
mod tracing_config;
mod tracing_helpers;
mod tracing_timing;

pub use builder::WireframeClientBuilder;
pub use codec_config::ClientCodecConfig;
Expand Down
Loading
Loading