v0.2.7-pre.008

This commit is contained in:
2026-08-22 21:44:18 +02:00
parent 4c540d67a7
commit 6fefc64e75
13 changed files with 804 additions and 125 deletions

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/lib.rs
// version: 24
// version: 25
#![warn(missing_docs)]
#![deny(unreachable_pub)]
@@ -16,10 +16,13 @@
//! modern/legacy `getTransaction` coverage. `0.2.4` completes the HTTP surface with all ten Blocks and five Economics wrappers, including complete
//! modern/legacy `getBlock`, positional inflation rewards, runtime-provided economics values and the final `KSP-TRANSPORT-007` compliance target.
//! The candidate surface therefore exposes typed wrappers for all 52 current audited Solana HTTP methods while retaining 14 removed historical descriptors.
//! `0.2.7-pre.002` adds the provider-neutral WebSocket settings foundation, redacted endpoint URLs, explicit protocol-family discrimination, local session/subscription
//! identities, observable lifecycle states and safe snapshots. `0.2.7-pre.004` adds the first physical WebSocket runtime: bounded handshake, one actor-owned socket,
//! bounded command/pending JSON-RPC flow, safe session snapshots and deterministic local-server fixtures. `0.2.7-pre.005` adds explicit bounded shutdown,
//! request/frame/message adversarial limits, control-frame handling and pending-request cancellation/timeout cleanup. Subscription registration remains deferred.
//! `0.2.7-pre.002` adds the provider-neutral WebSocket settings foundation, redacted endpoint URLs, explicit protocol-family discrimination, local session and
//! subscription identities, observable lifecycle states and safe snapshots. `0.2.7-pre.004` adds the first physical WebSocket runtime with one
//! actor-owned socket,
//! bounded handshake, command/pending JSON-RPC flow and deterministic local-server fixtures. `0.2.7-pre.005` adds explicit bounded shutdown, adversarial
//! request/frame/message limits and control-frame handling. `0.2.7-pre.006` adds the typed subscription registry with stable local IDs and internal remote-ID
//! routing. `0.2.7-pre.007` adds finite reconnect, deterministic resubscribe and continuity-gap tracking. `0.2.7-pre.008` makes per-subscription notification
//! backpressure terminal and observable, preserves safe terminal error codes, performs best-effort remote cleanup and proves bounded capacity reuse.
mod client;
mod constants;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
// version: 4
// version: 5
/// Stable local identity assigned to one physical WebSocket session.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
@@ -170,13 +170,20 @@ pub struct WsSubscriptionSnapshot {
kind: crate::WsSubscriptionKind,
state: crate::WsSubscriptionState,
remote_bound: bool,
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
}
impl WsSubscriptionSnapshot {
/// Creates one safe subscription lifecycle projection for Transport runtime internals.
#[must_use]
pub(crate) const fn new(id: crate::WsSubscriptionId, kind: crate::WsSubscriptionKind, state: crate::WsSubscriptionState, remote_bound: bool) -> Self {
return Self { id, kind, state, remote_bound };
pub(crate) const fn new(
id: crate::WsSubscriptionId,
kind: crate::WsSubscriptionKind,
state: crate::WsSubscriptionState,
remote_bound: bool,
terminal_error_code: std::option::Option<ksp_core_lib::ErrorCode>,
) -> Self {
return Self { id, kind, state, remote_bound, terminal_error_code };
}
/// Returns the stable local subscription identity.
@@ -204,6 +211,12 @@ impl WsSubscriptionSnapshot {
pub const fn remote_bound(&self) -> bool {
return self.remote_bound;
}
/// Returns the safe terminal error code when this subscription ended because of a failure.
#[must_use]
pub const fn terminal_error_code(&self) -> std::option::Option<ksp_core_lib::ErrorCode> {
return self.terminal_error_code;
}
}
/// Safe runtime snapshot for one physical WebSocket session.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_session.rs
// version: 7
// version: 8
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
@@ -275,10 +275,16 @@ enum WsActorIoOutcome {
Continue,
RemoteClosed,
ShutdownRequested { deadline: tokio::time::Instant },
StaleSubscribeAck { kind: crate::WsSubscriptionKind, remote_id: u64 },
BestEffortUnsubscribe { kind: crate::WsSubscriptionKind, remote_id: u64 },
Failed { code: ksp_core_lib::ErrorCode, pending_message: &'static str },
}
#[derive(Clone, Copy, Default)]
struct WsRuntimeCounters {
continuity_gap_count: u64,
overflow_count: u64,
}
enum WsReconnectOutcome {
Connected { websocket: std::boxed::Box<WsPhysicalStream> },
ShutdownRequested { deadline: tokio::time::Instant },
@@ -323,7 +329,7 @@ async fn run_ws_session_actor(
let (mut websocket, handshake_status) = match connect_wait {
std::result::Result::Ok(std::result::Result::Ok((websocket, response))) => (websocket, response.status().as_u16()),
std::result::Result::Ok(std::result::Result::Err(_)) => {
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Failed, 0, 0, &std::collections::BTreeMap::new());
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Failed, 0, WsRuntimeCounters::default(), &std::collections::BTreeMap::new());
let error = ws_connection_error(id, &endpoint, "WebSocket connection or handshake failed");
let _ = startup_tx.send(std::result::Result::Err(error));
ksp_logging_lib::warn!(
@@ -337,7 +343,7 @@ async fn run_ws_session_actor(
return;
},
std::result::Result::Err(_) => {
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Failed, 0, 0, &std::collections::BTreeMap::new());
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Failed, 0, WsRuntimeCounters::default(), &std::collections::BTreeMap::new());
let error = ws_timeout_error(id, "WebSocket connection handshake timed out");
let _ = startup_tx.send(std::result::Result::Err(error));
ksp_logging_lib::warn!(
@@ -350,7 +356,16 @@ async fn run_ws_session_actor(
},
};
let mut continuity_gap_count = 0_u64;
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Active, 0, continuity_gap_count, &std::collections::BTreeMap::new());
let mut overflow_count = 0_u64;
publish_snapshot(
&snapshot_tx,
id,
&endpoint,
crate::WsSessionState::Active,
0,
WsRuntimeCounters { continuity_gap_count, overflow_count },
&std::collections::BTreeMap::new(),
);
let _ = startup_tx.send(std::result::Result::Ok(()));
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
@@ -388,6 +403,7 @@ async fn run_ws_session_actor(
&mut subscriptions,
&mut remote_to_local,
continuity_gap_count,
overflow_count,
deadline,
)
.await;
@@ -417,6 +433,7 @@ async fn run_ws_session_actor(
&mut pending,
&mut subscriptions,
&mut remote_to_local,
&mut overflow_count,
&mut shutdown_rx,
)
.await
@@ -428,10 +445,18 @@ async fn run_ws_session_actor(
};
match outcome {
WsActorIoOutcome::Continue => {
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Active, pending.len(), continuity_gap_count, &subscriptions);
publish_snapshot(
&snapshot_tx,
id,
&endpoint,
crate::WsSessionState::Active,
pending.len(),
WsRuntimeCounters { continuity_gap_count, overflow_count },
&subscriptions,
);
},
WsActorIoOutcome::StaleSubscribeAck { kind, remote_id } => {
let cleanup = best_effort_stale_unsubscribe(id, &endpoint, &mut websocket, &mut shutdown_rx, kind, remote_id).await;
WsActorIoOutcome::BestEffortUnsubscribe { kind, remote_id } => {
let cleanup = best_effort_remote_unsubscribe(id, &endpoint, &mut websocket, &mut shutdown_rx, kind, remote_id).await;
if let WsActorIoOutcome::ShutdownRequested { deadline } = cleanup {
close_session_actor(
id,
@@ -442,12 +467,21 @@ async fn run_ws_session_actor(
&mut subscriptions,
&mut remote_to_local,
continuity_gap_count,
overflow_count,
deadline,
)
.await;
return;
}
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Active, pending.len(), continuity_gap_count, &subscriptions);
publish_snapshot(
&snapshot_tx,
id,
&endpoint,
crate::WsSessionState::Active,
pending.len(),
WsRuntimeCounters { continuity_gap_count, overflow_count },
&subscriptions,
);
},
WsActorIoOutcome::ShutdownRequested { deadline } => {
close_session_actor(
@@ -459,6 +493,7 @@ async fn run_ws_session_actor(
&mut subscriptions,
&mut remote_to_local,
continuity_gap_count,
overflow_count,
deadline,
)
.await;
@@ -476,6 +511,7 @@ async fn run_ws_session_actor(
&mut subscriptions,
&mut remote_to_local,
&mut continuity_gap_count,
&mut overflow_count,
crate::ERROR_CODE_WS_CONNECTION_FAILED,
"Remote peer closed WebSocket session before pending response delivery",
)
@@ -491,16 +527,35 @@ async fn run_ws_session_actor(
&mut subscriptions,
&mut remote_to_local,
continuity_gap_count,
overflow_count,
deadline,
);
return;
},
WsReconnectOutcome::HandlesDropped => {
finish_disconnected_close(id, &endpoint, &snapshot_tx, &mut pending, &mut subscriptions, &mut remote_to_local, continuity_gap_count);
finish_disconnected_close(
id,
&endpoint,
&snapshot_tx,
&mut pending,
&mut subscriptions,
&mut remote_to_local,
continuity_gap_count,
overflow_count,
);
return;
},
WsReconnectOutcome::Exhausted => {
finish_reconnect_exhaustion(id, &endpoint, &snapshot_tx, &mut pending, &mut subscriptions, &mut remote_to_local, continuity_gap_count);
finish_reconnect_exhaustion(
id,
&endpoint,
&snapshot_tx,
&mut pending,
&mut subscriptions,
&mut remote_to_local,
continuity_gap_count,
overflow_count,
);
return;
},
}
@@ -517,6 +572,7 @@ async fn run_ws_session_actor(
&mut subscriptions,
&mut remote_to_local,
&mut continuity_gap_count,
&mut overflow_count,
code,
pending_message,
)
@@ -532,16 +588,35 @@ async fn run_ws_session_actor(
&mut subscriptions,
&mut remote_to_local,
continuity_gap_count,
overflow_count,
deadline,
);
return;
},
WsReconnectOutcome::HandlesDropped => {
finish_disconnected_close(id, &endpoint, &snapshot_tx, &mut pending, &mut subscriptions, &mut remote_to_local, continuity_gap_count);
finish_disconnected_close(
id,
&endpoint,
&snapshot_tx,
&mut pending,
&mut subscriptions,
&mut remote_to_local,
continuity_gap_count,
overflow_count,
);
return;
},
WsReconnectOutcome::Exhausted => {
finish_reconnect_exhaustion(id, &endpoint, &snapshot_tx, &mut pending, &mut subscriptions, &mut remote_to_local, continuity_gap_count);
finish_reconnect_exhaustion(
id,
&endpoint,
&snapshot_tx,
&mut pending,
&mut subscriptions,
&mut remote_to_local,
continuity_gap_count,
overflow_count,
);
return;
},
}
@@ -570,11 +645,12 @@ async fn recover_websocket_session(
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
continuity_gap_count: &mut u64,
overflow_count: &mut u64,
code: ksp_core_lib::ErrorCode,
pending_message: &'static str,
) -> WsReconnectOutcome {
fail_pending_for_reconnect(pending, id, code, pending_message, subscriptions, remote_to_local);
prepare_subscriptions_for_reconnect(endpoint.session().resubscribe(), subscriptions, remote_to_local);
prepare_subscriptions_for_reconnect(endpoint.session().resubscribe(), subscriptions, remote_to_local, code);
*continuity_gap_count = (*continuity_gap_count).saturating_add(1);
let max_retries = endpoint.session().reconnect().max_retries();
if max_retries == 0 {
@@ -582,7 +658,15 @@ async fn recover_websocket_session(
}
let mut attempt = 1_u32;
while attempt <= max_retries {
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Reconnecting { attempt }, pending.len(), *continuity_gap_count, subscriptions);
publish_snapshot(
snapshot_tx,
id,
endpoint,
crate::WsSessionState::Reconnecting { attempt },
pending.len(),
WsRuntimeCounters { continuity_gap_count: *continuity_gap_count, overflow_count: *overflow_count },
subscriptions,
);
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
session_id = id.get(),
@@ -631,12 +715,21 @@ async fn recover_websocket_session(
subscriptions,
remote_to_local,
*continuity_gap_count,
overflow_count,
attempt,
)
.await;
match restore {
WsActorIoOutcome::Continue => {
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Active, pending.len(), *continuity_gap_count, subscriptions);
publish_snapshot(
snapshot_tx,
id,
endpoint,
crate::WsSessionState::Active,
pending.len(),
WsRuntimeCounters { continuity_gap_count: *continuity_gap_count, overflow_count: *overflow_count },
subscriptions,
);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
session_id = id.get(),
@@ -657,13 +750,13 @@ async fn recover_websocket_session(
subscriptions,
remote_to_local,
);
prepare_subscriptions_for_reconnect(endpoint.session().resubscribe(), subscriptions, remote_to_local);
prepare_subscriptions_for_reconnect(endpoint.session().resubscribe(), subscriptions, remote_to_local, crate::ERROR_CODE_WS_CONNECTION_FAILED);
},
WsActorIoOutcome::Failed { code: restore_code, pending_message: restore_message } => {
fail_pending_for_reconnect(pending, id, restore_code, restore_message, subscriptions, remote_to_local);
prepare_subscriptions_for_reconnect(endpoint.session().resubscribe(), subscriptions, remote_to_local);
prepare_subscriptions_for_reconnect(endpoint.session().resubscribe(), subscriptions, remote_to_local, restore_code);
},
WsActorIoOutcome::StaleSubscribeAck { .. } => {},
WsActorIoOutcome::BestEffortUnsubscribe { .. } => {},
}
attempt = attempt.saturating_add(1);
}
@@ -683,6 +776,7 @@ async fn restore_subscriptions_after_reconnect(
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
continuity_gap_count: u64,
overflow_count: &mut u64,
attempt: u32,
) -> WsActorIoOutcome {
let restore_ids = subscriptions
@@ -700,13 +794,14 @@ async fn restore_subscriptions_after_reconnect(
let (request_id, deadline) = match write_result {
std::result::Result::Ok(result) => result,
std::result::Result::Err((error, WsActorIoOutcome::Continue)) => {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
let error_code = error.code();
fail_local_subscription(subscriptions, remote_to_local, subscription_id, error_code);
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
session_id = id.get(),
subscription_id = subscription_id.get(),
subscription_kind = kind.as_str(),
error_code = error.code().code(),
error_code = error_code.code(),
"logical WebSocket resubscribe could not be queued"
);
continue;
@@ -752,7 +847,7 @@ async fn restore_subscriptions_after_reconnect(
}
},
maybe_message = websocket.next() => {
handle_socket_message(id, endpoint, maybe_message, websocket, pending, subscriptions, remote_to_local, shutdown_rx).await
handle_socket_message(id, endpoint, maybe_message, websocket, pending, subscriptions, remote_to_local, overflow_count, shutdown_rx).await
},
() = tokio::time::sleep_until(timeout_deadline) => {
expire_pending_requests(id, pending, subscriptions, remote_to_local);
@@ -761,15 +856,23 @@ async fn restore_subscriptions_after_reconnect(
};
match outcome {
WsActorIoOutcome::Continue => {},
WsActorIoOutcome::StaleSubscribeAck { kind: stale_kind, remote_id } => {
let cleanup = best_effort_stale_unsubscribe(id, endpoint, websocket, shutdown_rx, stale_kind, remote_id).await;
WsActorIoOutcome::BestEffortUnsubscribe { kind: stale_kind, remote_id } => {
let cleanup = best_effort_remote_unsubscribe(id, endpoint, websocket, shutdown_rx, stale_kind, remote_id).await;
if let WsActorIoOutcome::ShutdownRequested { deadline } = cleanup {
return WsActorIoOutcome::ShutdownRequested { deadline };
}
},
_ => return outcome,
}
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Reconnecting { attempt }, pending.len(), continuity_gap_count, subscriptions);
publish_snapshot(
snapshot_tx,
id,
endpoint,
crate::WsSessionState::Reconnecting { attempt },
pending.len(),
WsRuntimeCounters { continuity_gap_count, overflow_count: *overflow_count },
subscriptions,
);
}
}
return WsActorIoOutcome::Continue;
@@ -791,7 +894,7 @@ async fn handle_reconnecting_command_with_socket(
});
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Closed);
if let std::option::Option::Some((kind, remote_id)) = cleanup {
let cleanup_outcome = best_effort_stale_unsubscribe(id, endpoint, websocket, shutdown_rx, kind, remote_id).await;
let cleanup_outcome = best_effort_remote_unsubscribe(id, endpoint, websocket, shutdown_rx, kind, remote_id).await;
if let WsActorIoOutcome::ShutdownRequested { deadline } = cleanup_outcome {
let _ = response_tx.send(std::result::Result::Ok(false));
return WsActorIoOutcome::ShutdownRequested { deadline };
@@ -943,9 +1046,11 @@ fn prepare_subscriptions_for_reconnect(
policy: crate::WsResubscribePolicy,
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
failure_code: ksp_core_lib::ErrorCode,
) {
remote_to_local.clear();
let mut terminal = std::vec::Vec::new();
let mut failed = std::vec::Vec::new();
let mut already_terminal = std::vec::Vec::new();
for runtime in subscriptions.values_mut() {
runtime.remote_id = std::option::Option::None;
match runtime.state {
@@ -953,19 +1058,18 @@ fn prepare_subscriptions_for_reconnect(
if policy == crate::WsResubscribePolicy::ActiveSubscriptions {
runtime.set_state(crate::WsSubscriptionState::Resubscribing);
} else {
runtime.set_state(crate::WsSubscriptionState::Failed);
terminal.push(runtime.id);
failed.push(runtime.id);
}
},
crate::WsSubscriptionState::Requested | crate::WsSubscriptionState::Cancelling => {
runtime.set_state(crate::WsSubscriptionState::Failed);
terminal.push(runtime.id);
},
crate::WsSubscriptionState::Closed | crate::WsSubscriptionState::Failed => terminal.push(runtime.id),
crate::WsSubscriptionState::Requested | crate::WsSubscriptionState::Cancelling => failed.push(runtime.id),
crate::WsSubscriptionState::Closed | crate::WsSubscriptionState::Failed => already_terminal.push(runtime.id),
}
}
for subscription_id in terminal {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
for subscription_id in failed {
fail_local_subscription(subscriptions, remote_to_local, subscription_id, failure_code);
}
for subscription_id in already_terminal {
subscriptions.remove(&subscription_id.get());
}
}
@@ -998,7 +1102,7 @@ fn fail_pending_for_reconnect(
let _ = response_tx.send(std::result::Result::Err(error));
},
PendingWsResponse::Subscribe { subscription_id, response_tx } => {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
fail_local_subscription(subscriptions, remote_to_local, subscription_id, code);
let _ = response_tx.send(std::result::Result::Err(error));
},
PendingWsResponse::Resubscribe { .. } => {},
@@ -1010,7 +1114,7 @@ fn fail_pending_for_reconnect(
}
}
async fn best_effort_stale_unsubscribe(
async fn best_effort_remote_unsubscribe(
id: crate::WsSessionId,
endpoint: &crate::WsEndpointSettings,
websocket: &mut WsPhysicalStream,
@@ -1041,7 +1145,7 @@ async fn best_effort_stale_unsubscribe(
target: crate::TRACING_TARGET,
session_id = id.get(),
subscription_kind = kind.as_str(),
"sent best-effort unsubscribe for stale remote subscription acknowledgement"
"sent best-effort remote WebSocket unsubscribe"
);
}
return WsActorIoOutcome::Continue;
@@ -1055,12 +1159,21 @@ fn finish_disconnected_shutdown(
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
continuity_gap_count: u64,
overflow_count: u64,
_deadline: tokio::time::Instant,
) {
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closing, pending.len(), continuity_gap_count, subscriptions);
publish_snapshot(
snapshot_tx,
id,
endpoint,
crate::WsSessionState::Closing,
pending.len(),
WsRuntimeCounters { continuity_gap_count, overflow_count },
subscriptions,
);
fail_all_pending(pending, id, crate::ERROR_CODE_WS_SESSION_CLOSED, "WebSocket session shutdown cancelled pending request");
terminate_all_subscriptions(subscriptions, remote_to_local, crate::WsSubscriptionState::Closed);
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closed, 0, continuity_gap_count, subscriptions);
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closed, 0, WsRuntimeCounters { continuity_gap_count, overflow_count }, subscriptions);
}
fn finish_disconnected_close(
@@ -1071,10 +1184,11 @@ fn finish_disconnected_close(
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
continuity_gap_count: u64,
overflow_count: u64,
) {
fail_all_pending(pending, id, crate::ERROR_CODE_WS_SESSION_CLOSED, "WebSocket session handles were dropped during reconnect");
terminate_all_subscriptions(subscriptions, remote_to_local, crate::WsSubscriptionState::Closed);
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closed, 0, continuity_gap_count, subscriptions);
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closed, 0, WsRuntimeCounters { continuity_gap_count, overflow_count }, subscriptions);
}
fn finish_reconnect_exhaustion(
@@ -1085,10 +1199,11 @@ fn finish_reconnect_exhaustion(
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
continuity_gap_count: u64,
overflow_count: u64,
) {
fail_all_pending(pending, id, crate::ERROR_CODE_WS_CONNECTION_FAILED, "WebSocket reconnect budget was exhausted");
terminate_all_subscriptions(subscriptions, remote_to_local, crate::WsSubscriptionState::Failed);
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Failed, 0, continuity_gap_count, subscriptions);
fail_all_subscriptions(subscriptions, remote_to_local, crate::ERROR_CODE_WS_CONNECTION_FAILED);
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Failed, 0, WsRuntimeCounters { continuity_gap_count, overflow_count }, subscriptions);
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
session_id = id.get(),
@@ -1154,6 +1269,7 @@ where
},
};
let (state_tx, _) = tokio::sync::watch::channel(crate::WsSubscriptionState::Requested);
let (terminal_error_tx, _) = tokio::sync::watch::channel(std::option::Option::None::<ksp_core_lib::ErrorCode>);
let resubscribe_params = params.clone();
subscriptions.insert(
subscription_id.get(),
@@ -1164,6 +1280,7 @@ where
params: resubscribe_params,
remote_id: std::option::Option::None,
state_tx,
terminal_error_tx,
dispatcher,
},
);
@@ -1182,9 +1299,8 @@ where
WsActorIoOutcome::Continue
},
std::result::Result::Err((error, outcome)) => {
if let std::option::Option::Some(mut runtime) = subscriptions.remove(&subscription_id.get()) {
runtime.set_state(crate::WsSubscriptionState::Failed);
}
let error_code = error.code();
fail_local_subscription(subscriptions, remote_to_local, subscription_id, error_code);
let _ = response_tx.send(std::result::Result::Err(error));
outcome
},
@@ -1367,6 +1483,7 @@ async fn handle_socket_message<S>(
pending: &mut std::collections::BTreeMap<u64, PendingWsRequest>,
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
overflow_count: &mut u64,
shutdown_rx: &mut tokio::sync::watch::Receiver<std::option::Option<tokio::time::Instant>>,
) -> WsActorIoOutcome
where
@@ -1399,7 +1516,7 @@ where
},
};
return match message {
tokio_tungstenite::tungstenite::Message::Text(text) => handle_text_message(id, text.as_str(), pending, subscriptions, remote_to_local),
tokio_tungstenite::tungstenite::Message::Text(text) => handle_text_message(id, text.as_str(), pending, subscriptions, remote_to_local, overflow_count),
tokio_tungstenite::tungstenite::Message::Binary(_) => {
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, session_id = id.get(), "received unexpected binary WebSocket message");
WsActorIoOutcome::Failed {
@@ -1453,6 +1570,7 @@ fn handle_text_message(
pending: &mut std::collections::BTreeMap<u64, PendingWsRequest>,
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
overflow_count: &mut u64,
) -> WsActorIoOutcome {
let decoded = serde_json::from_str::<serde_json::Value>(text);
let value = match decoded {
@@ -1473,7 +1591,7 @@ fn handle_text_message(
},
};
if !object.contains_key("id") {
return handle_subscription_notification(id, object, subscriptions, remote_to_local);
return handle_subscription_notification(id, object, subscriptions, remote_to_local, overflow_count);
}
let response_id = match object.get("id").and_then(serde_json::Value::as_u64) {
std::option::Option::Some(response_id) => response_id,
@@ -1538,7 +1656,7 @@ fn dispatch_pending_response(
let remote_id = match value.as_u64() {
std::option::Option::Some(remote_id) => remote_id,
std::option::Option::None => {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
fail_local_subscription(subscriptions, remote_to_local, subscription_id, crate::ERROR_CODE_WS_PROTOCOL_ERROR);
let error = ksp_core_lib::Error::new(
crate::ERROR_CODE_WS_PROTOCOL_ERROR,
"WebSocket subscribe response did not contain a numeric remote subscription id",
@@ -1553,7 +1671,7 @@ fn dispatch_pending_response(
},
};
if remote_to_local.contains_key(&remote_id) {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
fail_local_subscription(subscriptions, remote_to_local, subscription_id, crate::ERROR_CODE_WS_PROTOCOL_ERROR);
let error = ksp_core_lib::Error::new(crate::ERROR_CODE_WS_PROTOCOL_ERROR, "WebSocket endpoint reused an active remote subscription id")
.with_context("session_id", id.get().to_string())
.with_context("subscription_id", subscription_id.get().to_string());
@@ -1568,7 +1686,12 @@ fn dispatch_pending_response(
runtime.remote_id = std::option::Option::Some(remote_id);
runtime.set_state(crate::WsSubscriptionState::Active);
remote_to_local.insert(remote_id, subscription_id);
crate::WsSubscriptionRegistration::new(subscription_id, runtime.kind, runtime.state_tx.subscribe())
crate::WsSubscriptionRegistration::new(
subscription_id,
runtime.kind,
runtime.state_tx.subscribe(),
runtime.terminal_error_tx.subscribe(),
)
},
std::option::Option::None => {
let error = ksp_core_lib::Error::new(
@@ -1593,7 +1716,8 @@ fn dispatch_pending_response(
);
},
std::result::Result::Err(error) => {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
let error_code = error.code();
fail_local_subscription(subscriptions, remote_to_local, subscription_id, error_code);
let _ = response_tx.send(std::result::Result::Err(error.with_context("method", pending_request.method)));
},
},
@@ -1602,7 +1726,7 @@ fn dispatch_pending_response(
let remote_id = match value.as_u64() {
std::option::Option::Some(remote_id) => remote_id,
std::option::Option::None => {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
fail_local_subscription(subscriptions, remote_to_local, subscription_id, crate::ERROR_CODE_WS_PROTOCOL_ERROR);
return WsActorIoOutcome::Failed {
code: crate::ERROR_CODE_WS_PROTOCOL_ERROR,
pending_message: "WebSocket resubscribe response violated protocol invariants",
@@ -1610,7 +1734,7 @@ fn dispatch_pending_response(
},
};
if remote_to_local.contains_key(&remote_id) {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
fail_local_subscription(subscriptions, remote_to_local, subscription_id, crate::ERROR_CODE_WS_PROTOCOL_ERROR);
return WsActorIoOutcome::Failed {
code: crate::ERROR_CODE_WS_PROTOCOL_ERROR,
pending_message: "WebSocket endpoint reused an active remote subscription id during resubscribe",
@@ -1626,7 +1750,7 @@ fn dispatch_pending_response(
subscription_kind = kind.as_str(),
"late WebSocket resubscribe acknowledgement lost to local cancellation"
);
return WsActorIoOutcome::StaleSubscribeAck { kind, remote_id };
return WsActorIoOutcome::BestEffortUnsubscribe { kind, remote_id };
}
if let std::option::Option::Some(runtime) = subscriptions.get_mut(&subscription_id.get()) {
runtime.remote_id = std::option::Option::Some(remote_id);
@@ -1641,13 +1765,15 @@ fn dispatch_pending_response(
"logical WebSocket subscription restored with a new remote binding"
);
},
std::result::Result::Err(_) => {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
std::result::Result::Err(error) => {
let error_code = error.code();
fail_local_subscription(subscriptions, remote_to_local, subscription_id, error_code);
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
session_id = id.get(),
subscription_id = subscription_id.get(),
subscription_kind = kind.as_str(),
error_code = error_code.code(),
"remote WebSocket resubscribe returned an application error"
);
},
@@ -1700,6 +1826,7 @@ fn handle_subscription_notification(
object: &serde_json::Map<std::string::String, serde_json::Value>,
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
overflow_count: &mut u64,
) -> WsActorIoOutcome {
if object.get("jsonrpc").and_then(serde_json::Value::as_str) != std::option::Option::Some("2.0") {
return WsActorIoOutcome::Failed {
@@ -1767,38 +1894,50 @@ fn handle_subscription_notification(
subscription_kind = subscription_kind.as_str(),
"WebSocket notification method mismatched the registered subscription family"
);
close_local_subscription(subscriptions, remote_to_local, local_id, crate::WsSubscriptionState::Failed);
return WsActorIoOutcome::Continue;
fail_local_subscription(subscriptions, remote_to_local, local_id, crate::ERROR_CODE_WS_PROTOCOL_ERROR);
return WsActorIoOutcome::BestEffortUnsubscribe { kind: subscription_kind, remote_id };
}
let subscription_kind = runtime.kind;
let dispatch = (runtime.dispatcher)(result);
match dispatch {
crate::WsNotificationDispatchOutcome::Delivered => {},
return match dispatch {
crate::WsNotificationDispatchOutcome::Delivered => WsActorIoOutcome::Continue,
crate::WsNotificationDispatchOutcome::ReceiverClosed => {
close_local_subscription(subscriptions, remote_to_local, local_id, crate::WsSubscriptionState::Closed);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
session_id = id.get(),
subscription_id = local_id.get(),
subscription_kind = subscription_kind.as_str(),
"logical WebSocket subscription receiver was dropped; scheduling remote cleanup"
);
WsActorIoOutcome::BestEffortUnsubscribe { kind: subscription_kind, remote_id }
},
crate::WsNotificationDispatchOutcome::QueueFull => {
*overflow_count = (*overflow_count).saturating_add(1);
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
session_id = id.get(),
subscription_id = local_id.get(),
subscription_kind = subscription_kind.as_str(),
"bounded WebSocket subscription notification queue is full"
overflow_count = *overflow_count,
"bounded WebSocket subscription notification queue overflowed; failing only the slow subscription"
);
close_local_subscription(subscriptions, remote_to_local, local_id, crate::WsSubscriptionState::Failed);
fail_local_subscription(subscriptions, remote_to_local, local_id, crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW);
WsActorIoOutcome::BestEffortUnsubscribe { kind: subscription_kind, remote_id }
},
crate::WsNotificationDispatchOutcome::DecodeFailed => {
crate::WsNotificationDispatchOutcome::DecodeFailed { code } => {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
session_id = id.get(),
subscription_id = local_id.get(),
subscription_kind = subscription_kind.as_str(),
error_code = code.code(),
"typed WebSocket notification decoding failed for one subscription"
);
close_local_subscription(subscriptions, remote_to_local, local_id, crate::WsSubscriptionState::Failed);
fail_local_subscription(subscriptions, remote_to_local, local_id, code);
WsActorIoOutcome::BestEffortUnsubscribe { kind: subscription_kind, remote_id }
},
}
return WsActorIoOutcome::Continue;
};
}
async fn close_session_actor<S>(
@@ -1810,14 +1949,23 @@ async fn close_session_actor<S>(
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
continuity_gap_count: u64,
overflow_count: u64,
deadline: tokio::time::Instant,
) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + std::marker::Unpin,
{
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closing, pending.len(), continuity_gap_count, subscriptions);
publish_snapshot(
snapshot_tx,
id,
endpoint,
crate::WsSessionState::Closing,
pending.len(),
WsRuntimeCounters { continuity_gap_count, overflow_count },
subscriptions,
);
fail_all_pending(pending, id, crate::ERROR_CODE_WS_SESSION_CLOSED, "WebSocket session shutdown cancelled the pending request");
terminate_all_subscriptions(subscriptions, remote_to_local, crate::WsSubscriptionState::Closed);
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closing, 0, continuity_gap_count, subscriptions);
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closing, 0, WsRuntimeCounters { continuity_gap_count, overflow_count }, subscriptions);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
session_id = id.get(),
@@ -1839,7 +1987,7 @@ async fn close_session_actor<S>(
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, session_id = id.get(), "best-effort WebSocket Close frame reached close deadline");
},
}
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closed, 0, continuity_gap_count, subscriptions);
publish_snapshot(snapshot_tx, id, endpoint, crate::WsSessionState::Closed, 0, WsRuntimeCounters { continuity_gap_count, overflow_count }, subscriptions);
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, session_id = id.get(), endpoint_name = endpoint.name(), "physical WebSocket session is closed");
}
@@ -1921,11 +2069,11 @@ fn expire_pending_response(
let _ = response_tx.send(std::result::Result::Err(error));
},
PendingWsResponse::Subscribe { subscription_id, response_tx } => {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
fail_local_subscription(subscriptions, remote_to_local, subscription_id, crate::ERROR_CODE_TIMEOUT);
let _ = response_tx.send(std::result::Result::Err(error));
},
PendingWsResponse::Resubscribe { subscription_id, .. } => {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Failed);
fail_local_subscription(subscriptions, remote_to_local, subscription_id, crate::ERROR_CODE_TIMEOUT);
},
PendingWsResponse::Unsubscribe { subscription_id, response_tx } => {
close_local_subscription(subscriptions, remote_to_local, subscription_id, crate::WsSubscriptionState::Closed);
@@ -2026,6 +2174,32 @@ fn terminate_all_subscriptions(
remote_to_local.clear();
}
fn fail_all_subscriptions(
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
error_code: ksp_core_lib::ErrorCode,
) {
for runtime in subscriptions.values_mut() {
runtime.remote_id = std::option::Option::None;
runtime.fail_with_code(error_code);
}
remote_to_local.clear();
}
fn fail_local_subscription(
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
subscription_id: crate::WsSubscriptionId,
error_code: ksp_core_lib::ErrorCode,
) {
if let std::option::Option::Some(mut runtime) = subscriptions.remove(&subscription_id.get()) {
if let std::option::Option::Some(remote_id) = runtime.remote_id.take() {
remote_to_local.remove(&remote_id);
}
runtime.fail_with_code(error_code);
}
}
fn close_local_subscription(
subscriptions: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
remote_to_local: &mut std::collections::BTreeMap<u64, crate::WsSubscriptionId>,
@@ -2046,7 +2220,7 @@ fn publish_snapshot(
endpoint: &crate::WsEndpointSettings,
state: crate::WsSessionState,
pending_request_count: usize,
continuity_gap_count: u64,
counters: WsRuntimeCounters,
subscriptions: &std::collections::BTreeMap<u64, crate::WsSubscriptionRuntime>,
) {
let subscription_snapshots = subscriptions.values().map(crate::WsSubscriptionRuntime::snapshot).collect();
@@ -2058,8 +2232,8 @@ fn publish_snapshot(
endpoint.protocol(),
state,
pending_request_count,
continuity_gap_count,
0,
counters.continuity_gap_count,
counters.overflow_count,
subscription_snapshots,
);
snapshot_tx.send_replace(snapshot);

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs
// version: 2
// version: 3
/// Typed handle for one logical Solana WebSocket subscription.
///
@@ -11,6 +11,7 @@ pub struct WsSubscription<T> {
kind: crate::WsSubscriptionKind,
notification_rx: tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>,
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
command_tx: tokio::sync::mpsc::Sender<crate::WsSessionCommand>,
command_timeout: std::time::Duration,
}
@@ -30,6 +31,7 @@ impl<T> WsSubscription<T> {
kind: registration.kind,
notification_rx,
state_rx: registration.state_rx,
terminal_error_rx: registration.terminal_error_rx,
command_tx,
command_timeout,
};
@@ -53,6 +55,14 @@ impl<T> WsSubscription<T> {
return *self.state_rx.borrow();
}
/// Returns the safe terminal error code when this logical subscription failed.
///
/// Successful local cancellation and normal completion use `None`. The value never contains remote payloads or endpoint credentials.
#[must_use]
pub fn terminal_error_code(&self) -> std::option::Option<ksp_core_lib::ErrorCode> {
return *self.terminal_error_rx.borrow();
}
/// Receives the next typed notification or terminal typed-decoding error.
///
/// The underlying queue is bounded by `WsSessionSettings::notification_queue_capacity`. `None` means the actor closed this logical subscription and no
@@ -116,7 +126,7 @@ pub(crate) enum WsNotificationDispatchOutcome {
Delivered,
ReceiverClosed,
QueueFull,
DecodeFailed,
DecodeFailed { code: ksp_core_lib::ErrorCode },
}
/// Type-erased actor-owned dispatcher for one heterogeneous typed notification channel.
@@ -130,16 +140,24 @@ where
{
let (notification_tx, notification_rx) = tokio::sync::mpsc::channel(capacity);
let dispatcher = move |value: serde_json::Value| -> WsNotificationDispatchOutcome {
let decoded = decoder(value);
return match decoded {
std::result::Result::Ok(notification) => match notification_tx.try_send(std::result::Result::Ok(notification)) {
std::result::Result::Ok(()) => WsNotificationDispatchOutcome::Delivered,
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => WsNotificationDispatchOutcome::ReceiverClosed,
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => WsNotificationDispatchOutcome::QueueFull,
let permit = match notification_tx.try_reserve() {
std::result::Result::Ok(permit) => permit,
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
return WsNotificationDispatchOutcome::ReceiverClosed;
},
std::result::Result::Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
return WsNotificationDispatchOutcome::QueueFull;
},
};
return match decoder(value) {
std::result::Result::Ok(notification) => {
permit.send(std::result::Result::Ok(notification));
WsNotificationDispatchOutcome::Delivered
},
std::result::Result::Err(error) => {
let _ = notification_tx.try_send(std::result::Result::Err(error));
WsNotificationDispatchOutcome::DecodeFailed
let code = error.code();
permit.send(std::result::Result::Err(error));
WsNotificationDispatchOutcome::DecodeFailed { code }
},
};
};
@@ -151,6 +169,7 @@ pub(crate) struct WsSubscriptionRegistration {
id: crate::WsSubscriptionId,
kind: crate::WsSubscriptionKind,
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
}
impl WsSubscriptionRegistration {
@@ -159,8 +178,9 @@ impl WsSubscriptionRegistration {
id: crate::WsSubscriptionId,
kind: crate::WsSubscriptionKind,
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
terminal_error_rx: tokio::sync::watch::Receiver<std::option::Option<ksp_core_lib::ErrorCode>>,
) -> Self {
return Self { id, kind, state_rx };
return Self { id, kind, state_rx, terminal_error_rx };
}
}
@@ -178,6 +198,8 @@ pub(crate) struct WsSubscriptionRuntime {
pub(crate) remote_id: std::option::Option<u64>,
/// Lifecycle publisher observed by the public typed handle.
pub(crate) state_tx: tokio::sync::watch::Sender<crate::WsSubscriptionState>,
/// Safe terminal failure code publisher observed by the public typed handle.
pub(crate) terminal_error_tx: tokio::sync::watch::Sender<std::option::Option<ksp_core_lib::ErrorCode>>,
/// Type-erased dispatcher into the bounded typed notification channel.
pub(crate) dispatcher: WsNotificationDispatcher,
}
@@ -185,7 +207,7 @@ pub(crate) struct WsSubscriptionRuntime {
impl WsSubscriptionRuntime {
/// Builds the safe session-snapshot projection for this runtime entry.
pub(crate) fn snapshot(&self) -> crate::WsSubscriptionSnapshot {
return crate::WsSubscriptionSnapshot::new(self.id, self.kind, self.state, self.remote_id.is_some());
return crate::WsSubscriptionSnapshot::new(self.id, self.kind, self.state, self.remote_id.is_some(), *self.terminal_error_tx.borrow());
}
/// Updates the runtime state and publishes it to the typed handle.
@@ -193,6 +215,12 @@ impl WsSubscriptionRuntime {
self.state = state;
self.state_tx.send_replace(state);
}
/// Publishes a terminal failure code before moving the logical subscription to `Failed`.
pub(crate) fn fail_with_code(&mut self, code: ksp_core_lib::ErrorCode) {
self.terminal_error_tx.send_replace(std::option::Option::Some(code));
self.set_state(crate::WsSubscriptionState::Failed);
}
}
fn subscription_closed_error(session_id: crate::WsSessionId, subscription_id: crate::WsSubscriptionId, message: &'static str) -> ksp_core_lib::Error {