Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion clients/rust/MULTI_HOST.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ Snapshots are immutable. To observe changes, listen to the connection-event stre

Each host runs in its own internal task, a `HostRuntime`, that owns the current `Client`, retries the configured `ReconnectPolicy`, and re-subscribes to known URIs across reconnects.

Connection readiness depends only on a successful `initialize` / `reconnect`.
The event receiver is installed before that handshake, and the client is
published before the session-cache refresh starts. `listSessions` is an ordinary
concurrent RPC: slow or failed discovery does not block other client requests.
Until it completes, `session_summaries` may be empty or retain the previous
connection's cache. Notifications received during the refresh are merged over
its result; cancelled or superseded refreshes cannot update a newer connection.

The underlying client's automatic keepalive checks inbound wire silence,
independently of discovery. Configure it with `HostConfig::with_client_config`
and `ClientConfig::keepalive`; a liveness timeout closes the connection and
enters the normal reconnect policy. Set `keepalive: None` to disable it.

Every successful reconnect bumps a per-host **generation** counter. Any `HostClientHandle` you obtained from a previous connection refuses to dispatch on the new one and returns `HostError::HostReconnected`; request a fresh handle in that case. This prevents subtle bugs where a handle held across a reconnect silently writes to a different connection.

## Stable `clientId` per host
Expand Down Expand Up @@ -122,7 +135,7 @@ handle.check_alive().await?;
# Ok(()) }
```

Configuration knobs live on `HostConfig` (`with_client_id`, `with_initial_subscriptions`, `with_client_config`, `with_reconnect_policy`) and on `ReconnectPolicy::{disabled, immediate_forever, exponential}`. For persistent identity across launches, plug in a persistent `ClientIdStore` via `MultiHostClient::with_client_id_store(...)` (see below) or load the `clientId` yourself and pass it through `HostConfig::with_client_id`.
Configuration knobs live on `HostConfig` (`with_client_id`, `with_initial_subscriptions`, `with_client_config`, `with_reconnect_policy`), `ClientConfig::keepalive`, and `ReconnectPolicy::{disabled, immediate_forever, exponential}`. For persistent identity across launches, plug in a persistent `ClientIdStore` via `MultiHostClient::with_client_id_store(...)` (see below) or load the `clientId` yourself and pass it through `HostConfig::with_client_id`.

## Persistent `clientId`s — `ClientIdStore`

Expand Down
2 changes: 1 addition & 1 deletion clients/rust/crates/ahp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ tracing = { workspace = true }
jiff = { workspace = true }

[dev-dependencies]
tokio = { workspace = true, features = ["full"] }
tokio = { workspace = true, features = ["full", "test-util"] }
serde_json = { workspace = true }
ahp-ws = { path = "../ahp-ws" }
tracing-subscriber = "0.3"
Expand Down
60 changes: 60 additions & 0 deletions clients/rust/crates/ahp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,66 @@ impl Transport for MyTransport {

See `tests/client_roundtrip.rs` for a complete in-memory example.

### Automatic idle keepalive

The client driver sends a root-channel `ping` after 30 seconds without received
wire traffic and closes the connection after 90 seconds of continuous inbound
silence. Any inbound frame proves liveness, not just a ping response. Outbound
requests and notifications do not reset these deadlines. There is at most one
automatic ping pending at a time, using the same ID allocator and response map
as ordinary requests. Automatic keepalive is independent of the request timeout
and requires no transport extension or separate heartbeat task.

Configure these deadlines, or disable keepalive, through `ClientConfig`:

```rust
use ahp::{ClientConfig, KeepaliveConfig};
use std::time::Duration;

let config = ClientConfig {
keepalive: Some(KeepaliveConfig {
idle_interval: Duration::from_secs(15),
liveness_timeout: Duration::from_secs(45),
}),
..ClientConfig::default()
};
let disabled = ClientConfig { keepalive: None, ..ClientConfig::default() };
```

The idle interval must be nonzero, and the liveness timeout must be greater
than it. Invalid policy is rejected by `Client::connect` before I/O. Consumers
constructing `ClientConfig` with all fields explicitly must add `keepalive`;
struct updates using `..ClientConfig::default()` remain source-compatible.
The wire protocol is unchanged. These are local SDK settings, not automatic
interpretation of host metadata or negotiated deadlines.

Because `Transport::send` and `recv` borrow the same transport, receive
observation pauses during a send. With keepalive enabled, each send is bounded
separately by `liveness_timeout` measured from the start of that write; a stalled
write is reported as a transport write timeout, not inbound silence. Transport
cleanup is bounded to five seconds, even if `close` cannot complete.

Keepalive ends with shutdown, transport closure, or dropping the last client.
Liveness failure closes event streams so managed hosts follow their normal
reconnect policy. In-flight normal requests retain their `-32000`
`ClientError::Rpc` teardown errors, and request timeouts remain
`ClientError::Cancelled`. Cancelling a request future removes its pending entry
without retracting an already-sent request.

Managed hosts become connected and expose their generation-checked client as
soon as `initialize` / `reconnect` completes, before `listSessions` resolves.
The concurrent session-cache refresh preserves intervening notifications and
cannot apply results after connection replacement. Refresh failures are logged
and do not change readiness.

Managed hosts retain their request ID allocator across connection attempts for
the lifetime of one host supervisor, including failed handshakes. Late replies
from an earlier transport cannot match a newly allocated request on that
logical host. Independent hosts and standalone `Client::connect` calls still
start independent ID sequences. Exhausting the `u64` request ID space fails
explicitly with `ClientError::Transport(TransportError::Protocol(_))` rather
than reusing an ID.

## See also

- [`ahp-types`](https://crates.io/crates/ahp-types) — wire types only (no I/O)
Expand Down
Loading