v0.2.7-pre.006
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||
<!-- version: 11 -->
|
||||
<!-- version: 12 -->
|
||||
|
||||
# `ksp-onchain-transport-lib`
|
||||
|
||||
@@ -99,9 +99,9 @@ La première session physique WebSocket est matérialisée sans introduire de po
|
||||
- publie `WsSessionSnapshot` via un état compact `watch` ;
|
||||
- applique aux sockets les plafonds KSP de message, frame et write buffer ;
|
||||
- ne projette jamais l'URL dans `Debug`, snapshot, erreurs KSP ou logs ;
|
||||
- répond aux `Ping` reçus et tolère les `Pong`; le lifecycle complet `Close`/shutdown reste le gate `pre.005`.
|
||||
- répond aux `Ping` reçus et tolère les `Pong`; le lifecycle complet `Close`/shutdown a été durci ensuite en `pre.005`.
|
||||
|
||||
Le chemin JSON-RPC générique reste `pub(crate)` en `pre.004`. Il sert de primitive au futur moteur typed de subscriptions et **ne constitue pas une API publique raw provider-extension**. Le registry subscriptions, les IDs serveur, reconnect/resubscribe et backpressure par subscription restent respectivement dans les tranches prévues.
|
||||
Le chemin JSON-RPC générique reste `pub(crate)`. Il sert de primitive au moteur typed de subscriptions et **ne constitue pas une API publique raw provider-extension**. Le registry subscriptions et le mapping remote/local sont matérialisés en `pre.006`; reconnect/resubscribe puis le durcissement backpressure restent dans les tranches suivantes.
|
||||
|
||||
Les tests déterministes utilisent un serveur WebSocket local et prouvent le handshake, le round-trip JSON-RPC, le dispatch de réponses hors ordre, l'isolation des erreurs RPC applicatives, deux sessions physiques distinctes sur la même URL et la redaction des erreurs de connexion.
|
||||
|
||||
@@ -113,6 +113,21 @@ Les limites `max_message_size`, `max_frame_size`, `max_write_buffer_size` et `ma
|
||||
|
||||
Ping/Pong/Close sont traités comme control frames : le Pong automatique Tungstenite est flushé, un Close distant propre mène à `Closed`, tandis qu'une erreur I/O ou une violation de protocole mène à `Failed`. Aucun heartbeat applicatif périodique n'est ajouté.
|
||||
|
||||
### Registry de subscriptions `0.2.7-pre.006`
|
||||
|
||||
Le même actor possède maintenant le registre des subscriptions logiques, sans exposer les IDs numériques distants. Chaque subscription reçoit un `WsSubscriptionId` local stable, et le mapping `remote_subscription_id -> WsSubscriptionId` reste strictement runtime/interne.
|
||||
|
||||
La création générique typed reste `pub(crate)` jusqu'aux wrappers standard des tranches `pre.009+`. Le handle public `WsSubscription<T>` expose uniquement :
|
||||
|
||||
- `id()` et `kind()` ;
|
||||
- `state()` ;
|
||||
- `recv()` sur un canal typed borné ;
|
||||
- `unsubscribe()` qui conserve le booléen retourné par l'unsubscribe Solana standard.
|
||||
|
||||
L'ACK de subscribe est traité atomiquement dans l'actor : le remote ID est lié au local ID avant que la notification suivante puisse être dispatchée. Les notifications inconnues/stale sont ignorées avec un diagnostic sûr. Un mismatch de méthode de notification ou un échec de décodage typed termine uniquement la subscription concernée ; la session physique reste `Active`.
|
||||
|
||||
Le reconnect/resubscribe et les races associées restent hors scope jusqu'à `pre.007`. Le durcissement adversarial final de backpressure/leaks reste `pre.008`.
|
||||
|
||||
## Résilience
|
||||
|
||||
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||
<!-- version: 11 -->
|
||||
<!-- version: 12 -->
|
||||
|
||||
# Utilisation de `ksp-onchain-transport-lib`
|
||||
|
||||
@@ -92,7 +92,11 @@ let snapshot = session.snapshot();
|
||||
|
||||
Deux appels `WsSession::connect` avec le même endpoint créent volontairement deux connexions physiques distinctes. Il n'existe encore aucun pool de sessions automatique.
|
||||
|
||||
Le socket brut et la primitive JSON-RPC générique ne sont pas publics. Les wrappers `*Subscribe` typed et leurs handles seront ajoutés au-dessus de l'actor ; `pre.004` ne doit donc pas être utilisé comme escape hatch provider-specific.
|
||||
Le socket brut et la primitive JSON-RPC générique ne sont pas publics. À partir de `pre.006`, le moteur générique de subscription typed existe dans la crate mais sa création reste `pub(crate)` jusqu'aux wrappers standard publics des tranches `pre.009+` ; il ne constitue donc toujours pas une escape hatch provider-specific.
|
||||
|
||||
`WsSubscription<T>` est déjà le handle public commun que ces wrappers retourneront. Il porte un `WsSubscriptionId` local stable, jamais le remote ID numérique du serveur. Les notifications arrivent via un receiver typed borné et `unsubscribe().await` exécute le `*Unsubscribe` correspondant en préservant son résultat booléen.
|
||||
|
||||
Le snapshot de session expose les subscriptions actuellement enregistrées via `WsSubscriptionSnapshot`, avec `remote_bound: bool` seulement. Le remote ID réel n'est jamais projeté.
|
||||
|
||||
### Fermeture explicite
|
||||
|
||||
@@ -108,7 +112,7 @@ session.close().await?;
|
||||
|
||||
Les limites de taille et de capacité sont des policies KSP configurables par `WsSessionSettings`; elles ne doivent pas être interprétées comme des limites protocolaires Solana officielles.
|
||||
|
||||
Le snapshot expose seulement l'identité locale, les metadata logiques de l'endpoint, l'état et les compteurs sûrs. L'URL n'est jamais projetée. Le shutdown async explicite arrive en `pre.005`; la disparition de tous les handles déclenche seulement le cleanup actor best-effort de cette foundation.
|
||||
Le snapshot expose seulement l'identité locale, les metadata logiques de l'endpoint, l'état, les compteurs sûrs et les projections locales de subscriptions. L'URL et les remote subscription IDs ne sont jamais projetés. La disparition de tous les handles de session déclenche le cleanup actor best-effort ; `close().await` reste la voie normale de shutdown.
|
||||
|
||||
## 4. Appels typés
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ mod settings;
|
||||
mod ws_lifecycle;
|
||||
mod ws_session;
|
||||
mod ws_settings;
|
||||
mod ws_subscription;
|
||||
|
||||
/// Passive runtime availability reported for one logical HTTP endpoint.
|
||||
pub use self::client::HttpEndpointAvailability;
|
||||
@@ -340,6 +341,8 @@ pub use self::ws_settings::WsResubscribePolicy;
|
||||
pub use self::ws_settings::WsSessionSettings;
|
||||
/// Complete runtime settings consumed by the KSP WebSocket transport foundation.
|
||||
pub use self::ws_settings::WsTransportSettings;
|
||||
/// Typed handle for one logical Solana WebSocket subscription.
|
||||
pub use self::ws_subscription::WsSubscription;
|
||||
|
||||
/// Owning tracing target for events emitted by the on-chain transport crate.
|
||||
pub(crate) use self::constants::TRACING_TARGET;
|
||||
@@ -355,3 +358,15 @@ pub(crate) use self::rpc_common::decode_wire_json;
|
||||
pub(crate) use self::rpc_common::parse_wire_pubkey;
|
||||
/// Validates endpoint settings.
|
||||
pub(crate) use self::settings::validate_endpoint_settings;
|
||||
/// Crate-internal command surface shared by the physical session and typed subscription handle.
|
||||
pub(crate) use self::ws_session::WsSessionCommand;
|
||||
/// Crate-internal notification dispatch result.
|
||||
pub(crate) use self::ws_subscription::WsNotificationDispatchOutcome;
|
||||
/// Crate-internal type-erased notification dispatcher.
|
||||
pub(crate) use self::ws_subscription::WsNotificationDispatcher;
|
||||
/// Crate-internal actor registration returned after subscribe acknowledgement.
|
||||
pub(crate) use self::ws_subscription::WsSubscriptionRegistration;
|
||||
/// Crate-internal actor-owned logical subscription runtime entry.
|
||||
pub(crate) use self::ws_subscription::WsSubscriptionRuntime;
|
||||
/// Crate-internal constructor for bounded typed notification channels.
|
||||
pub(crate) use self::ws_subscription::typed_notification_channel;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Stable local identity assigned to one physical WebSocket session.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
@@ -116,6 +116,51 @@ impl WsSubscriptionKind {
|
||||
Self::Vote => "vote",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact standard Solana subscribe JSON-RPC method for this family.
|
||||
pub(crate) const fn subscribe_method(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "accountSubscribe",
|
||||
Self::Block => "blockSubscribe",
|
||||
Self::Logs => "logsSubscribe",
|
||||
Self::Program => "programSubscribe",
|
||||
Self::Root => "rootSubscribe",
|
||||
Self::Signature => "signatureSubscribe",
|
||||
Self::Slot => "slotSubscribe",
|
||||
Self::SlotsUpdates => "slotsUpdatesSubscribe",
|
||||
Self::Vote => "voteSubscribe",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact standard Solana unsubscribe JSON-RPC method for this family.
|
||||
pub(crate) const fn unsubscribe_method(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "accountUnsubscribe",
|
||||
Self::Block => "blockUnsubscribe",
|
||||
Self::Logs => "logsUnsubscribe",
|
||||
Self::Program => "programUnsubscribe",
|
||||
Self::Root => "rootUnsubscribe",
|
||||
Self::Signature => "signatureUnsubscribe",
|
||||
Self::Slot => "slotUnsubscribe",
|
||||
Self::SlotsUpdates => "slotsUpdatesUnsubscribe",
|
||||
Self::Vote => "voteUnsubscribe",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the exact standard Solana notification method emitted for this family.
|
||||
pub(crate) const fn notification_method(self) -> &'static str {
|
||||
return match self {
|
||||
Self::Account => "accountNotification",
|
||||
Self::Block => "blockNotification",
|
||||
Self::Logs => "logsNotification",
|
||||
Self::Program => "programNotification",
|
||||
Self::Root => "rootNotification",
|
||||
Self::Signature => "signatureNotification",
|
||||
Self::Slot => "slotNotification",
|
||||
Self::SlotsUpdates => "slotsUpdatesNotification",
|
||||
Self::Vote => "voteNotification",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe lifecycle projection for one logical WebSocket subscription.
|
||||
@@ -130,7 +175,6 @@ pub struct WsSubscriptionSnapshot {
|
||||
impl WsSubscriptionSnapshot {
|
||||
/// Creates one safe subscription lifecycle projection for Transport runtime internals.
|
||||
#[must_use]
|
||||
#[cfg(test)]
|
||||
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 };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
205
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
Normal file
205
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
// file: crates/ksp-onchain-transport-lib/src/ws_subscription.rs
|
||||
// version: 1
|
||||
|
||||
/// Typed handle for one logical Solana WebSocket subscription.
|
||||
///
|
||||
/// The handle owns the bounded typed notification receiver while the physical session actor owns the remote subscription identity and socket. The remote
|
||||
/// numeric subscription identifier is intentionally never exposed because it is transient and will be remapped by reconnect support in later tranches.
|
||||
pub struct WsSubscription<T> {
|
||||
session_id: crate::WsSessionId,
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
notification_rx: tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>,
|
||||
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
|
||||
command_tx: tokio::sync::mpsc::Sender<crate::WsSessionCommand>,
|
||||
command_timeout: std::time::Duration,
|
||||
}
|
||||
|
||||
impl<T> WsSubscription<T> {
|
||||
/// Creates a typed subscription handle from one actor registration.
|
||||
pub(crate) fn new(
|
||||
session_id: crate::WsSessionId,
|
||||
registration: WsSubscriptionRegistration,
|
||||
notification_rx: tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>,
|
||||
command_tx: tokio::sync::mpsc::Sender<crate::WsSessionCommand>,
|
||||
command_timeout: std::time::Duration,
|
||||
) -> Self {
|
||||
return Self {
|
||||
session_id,
|
||||
id: registration.id,
|
||||
kind: registration.kind,
|
||||
notification_rx,
|
||||
state_rx: registration.state_rx,
|
||||
command_tx,
|
||||
command_timeout,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the stable local logical subscription identity.
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> crate::WsSubscriptionId {
|
||||
return self.id;
|
||||
}
|
||||
|
||||
/// Returns the standard Solana subscription family.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> crate::WsSubscriptionKind {
|
||||
return self.kind;
|
||||
}
|
||||
|
||||
/// Returns the latest observable lifecycle state for this logical subscription.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> crate::WsSubscriptionState {
|
||||
return *self.state_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
|
||||
/// further notifications can arrive.
|
||||
pub async fn recv(&mut self) -> std::option::Option<ksp_core_lib::Result<T>> {
|
||||
return self.notification_rx.recv().await;
|
||||
}
|
||||
|
||||
/// Cancels this logical subscription and sends the matching Solana unsubscribe request when a remote binding still exists.
|
||||
///
|
||||
/// The returned boolean preserves the standard Solana unsubscribe result. Local cancellation is terminal for this handle even when the remote endpoint
|
||||
/// returns an application error; reconnect race hardening is completed in `0.2.7-pre.007`.
|
||||
pub async fn unsubscribe(&mut self) -> ksp_core_lib::Result<bool> {
|
||||
match self.state() {
|
||||
crate::WsSubscriptionState::Closed => return std::result::Result::Ok(false),
|
||||
crate::WsSubscriptionState::Failed => {
|
||||
return std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket subscription is already failed"));
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
let command = crate::WsSessionCommand::Unsubscribe { subscription_id: self.id, response_tx };
|
||||
let send_wait = tokio::time::timeout(self.command_timeout, self.command_tx.send(command)).await;
|
||||
match send_wait {
|
||||
std::result::Result::Ok(std::result::Result::Ok(())) => {},
|
||||
std::result::Result::Ok(std::result::Result::Err(_)) => {
|
||||
return std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket session command channel is closed"));
|
||||
},
|
||||
std::result::Result::Err(_) => {
|
||||
return std::result::Result::Err(subscription_timeout_error(
|
||||
self.session_id,
|
||||
self.id,
|
||||
"WebSocket unsubscribe command queue remained unavailable until timeout",
|
||||
));
|
||||
},
|
||||
}
|
||||
return match response_rx.await {
|
||||
std::result::Result::Ok(result) => result,
|
||||
std::result::Result::Err(_) => {
|
||||
std::result::Result::Err(subscription_closed_error(self.session_id, self.id, "WebSocket session ended before unsubscribe completion"))
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> std::fmt::Debug for WsSubscription<T> {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return formatter
|
||||
.debug_struct("WsSubscription")
|
||||
.field("session_id", &self.session_id)
|
||||
.field("id", &self.id)
|
||||
.field("kind", &self.kind)
|
||||
.field("state", &self.state())
|
||||
.finish();
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal result of dispatching one decoded wire notification into a bounded typed channel.
|
||||
pub(crate) enum WsNotificationDispatchOutcome {
|
||||
Delivered,
|
||||
ReceiverClosed,
|
||||
QueueFull,
|
||||
DecodeFailed,
|
||||
}
|
||||
|
||||
/// Type-erased actor-owned dispatcher for one heterogeneous typed notification channel.
|
||||
pub(crate) type WsNotificationDispatcher = std::boxed::Box<dyn Fn(serde_json::Value) -> WsNotificationDispatchOutcome + std::marker::Send + std::marker::Sync>;
|
||||
|
||||
/// Creates one bounded typed notification receiver and its type-erased actor dispatcher.
|
||||
pub(crate) fn typed_notification_channel<T, F>(capacity: usize, decoder: F) -> (WsNotificationDispatcher, tokio::sync::mpsc::Receiver<ksp_core_lib::Result<T>>)
|
||||
where
|
||||
T: std::marker::Send + 'static,
|
||||
F: Fn(serde_json::Value) -> ksp_core_lib::Result<T> + std::marker::Send + std::marker::Sync + 'static,
|
||||
{
|
||||
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,
|
||||
},
|
||||
std::result::Result::Err(error) => {
|
||||
let _ = notification_tx.try_send(std::result::Result::Err(error));
|
||||
WsNotificationDispatchOutcome::DecodeFailed
|
||||
},
|
||||
};
|
||||
};
|
||||
return (std::boxed::Box::new(dispatcher), notification_rx);
|
||||
}
|
||||
|
||||
/// Internal registration returned after a remote subscribe acknowledgement becomes atomically bound.
|
||||
pub(crate) struct WsSubscriptionRegistration {
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
|
||||
}
|
||||
|
||||
impl WsSubscriptionRegistration {
|
||||
/// Creates one successful actor registration without exposing the transient remote identifier.
|
||||
pub(crate) fn new(
|
||||
id: crate::WsSubscriptionId,
|
||||
kind: crate::WsSubscriptionKind,
|
||||
state_rx: tokio::sync::watch::Receiver<crate::WsSubscriptionState>,
|
||||
) -> Self {
|
||||
return Self { id, kind, state_rx };
|
||||
}
|
||||
}
|
||||
|
||||
/// Actor-owned runtime entry for one local logical WebSocket subscription.
|
||||
pub(crate) struct WsSubscriptionRuntime {
|
||||
/// Stable local identity.
|
||||
pub(crate) id: crate::WsSubscriptionId,
|
||||
/// Standard Solana subscription family.
|
||||
pub(crate) kind: crate::WsSubscriptionKind,
|
||||
/// Current logical lifecycle state.
|
||||
pub(crate) state: crate::WsSubscriptionState,
|
||||
/// Current transient remote subscription identity when bound.
|
||||
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>,
|
||||
/// Type-erased dispatcher into the bounded typed notification channel.
|
||||
pub(crate) dispatcher: WsNotificationDispatcher,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/// Updates the runtime state and publishes it to the typed handle.
|
||||
pub(crate) fn set_state(&mut self, state: crate::WsSubscriptionState) {
|
||||
self.state = state;
|
||||
self.state_tx.send_replace(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription_closed_error(session_id: crate::WsSessionId, subscription_id: crate::WsSubscriptionId, message: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_WS_SESSION_CLOSED, message)
|
||||
.with_context("session_id", session_id.get().to_string())
|
||||
.with_context("subscription_id", subscription_id.get().to_string());
|
||||
}
|
||||
|
||||
fn subscription_timeout_error(session_id: crate::WsSessionId, subscription_id: crate::WsSubscriptionId, message: &'static str) -> ksp_core_lib::Error {
|
||||
return ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message)
|
||||
.with_context("session_id", session_id.get().to_string())
|
||||
.with_context("subscription_id", subscription_id.get().to_string());
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
// version: 27
|
||||
// version: 28
|
||||
|
||||
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||
|
||||
@@ -566,3 +566,14 @@ fn public_v0_2_7_pre_005_bounded_websocket_close_contract_is_available_from_crat
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSessionState::Closing, ksp_onchain_transport_lib::WsSessionState::Closing);
|
||||
assert_eq!(ksp_onchain_transport_lib::WsSessionState::Closed, ksp_onchain_transport_lib::WsSessionState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_v0_2_7_pre_006_typed_websocket_subscription_handle_is_available_from_crate_root() {
|
||||
let type_name = std::any::type_name::<ksp_onchain_transport_lib::WsSubscription<serde_json::Value>>();
|
||||
assert!(type_name.contains("WsSubscription"));
|
||||
let _id = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::id;
|
||||
let _kind = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::kind;
|
||||
let _state = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::state;
|
||||
let _recv = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::recv;
|
||||
let _unsubscribe = ksp_onchain_transport_lib::WsSubscription::<serde_json::Value>::unsubscribe;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
fn non_zero(value: u64) -> std::num::NonZeroU64 {
|
||||
return std::num::NonZeroU64::new(value).expect("test ID must be non-zero");
|
||||
@@ -68,3 +68,23 @@ fn websocket_snapshots_expose_safe_metadata_without_remote_ids_or_urls() {
|
||||
assert!(!rendered.contains("wss://"));
|
||||
assert!(!rendered.contains("remote_subscription_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_subscription_kinds_map_exact_standard_method_triplets() {
|
||||
let cases = [
|
||||
(crate::WsSubscriptionKind::Account, "accountSubscribe", "accountUnsubscribe", "accountNotification"),
|
||||
(crate::WsSubscriptionKind::Block, "blockSubscribe", "blockUnsubscribe", "blockNotification"),
|
||||
(crate::WsSubscriptionKind::Logs, "logsSubscribe", "logsUnsubscribe", "logsNotification"),
|
||||
(crate::WsSubscriptionKind::Program, "programSubscribe", "programUnsubscribe", "programNotification"),
|
||||
(crate::WsSubscriptionKind::Root, "rootSubscribe", "rootUnsubscribe", "rootNotification"),
|
||||
(crate::WsSubscriptionKind::Signature, "signatureSubscribe", "signatureUnsubscribe", "signatureNotification"),
|
||||
(crate::WsSubscriptionKind::Slot, "slotSubscribe", "slotUnsubscribe", "slotNotification"),
|
||||
(crate::WsSubscriptionKind::SlotsUpdates, "slotsUpdatesSubscribe", "slotsUpdatesUnsubscribe", "slotsUpdatesNotification"),
|
||||
(crate::WsSubscriptionKind::Vote, "voteSubscribe", "voteUnsubscribe", "voteNotification"),
|
||||
];
|
||||
for (kind, subscribe, unsubscribe, notification) in cases {
|
||||
assert_eq!(kind.subscribe_method(), subscribe);
|
||||
assert_eq!(kind.unsubscribe_method(), unsubscribe);
|
||||
assert_eq!(kind.notification_method(), notification);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
|
||||
// version: 2
|
||||
// version: 3
|
||||
|
||||
use futures_util::SinkExt; // rust-rules: trait-import
|
||||
use futures_util::StreamExt; // rust-rules: trait-import
|
||||
@@ -380,3 +380,185 @@ async fn websocket_repeated_connect_close_cycles_are_bounded() {
|
||||
.expect("repeated close server must remain bounded")
|
||||
.expect("repeated close server task must join");
|
||||
}
|
||||
|
||||
async fn send_notification(
|
||||
websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>,
|
||||
method: &str,
|
||||
remote_subscription_id: u64,
|
||||
result: serde_json::Value,
|
||||
) {
|
||||
let notification = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": {
|
||||
"result": result,
|
||||
"subscription": remote_subscription_id
|
||||
}
|
||||
});
|
||||
websocket.send(tokio_tungstenite::tungstenite::Message::Text(notification.to_string().into())).await.expect("local notification must send");
|
||||
}
|
||||
|
||||
async fn wait_for_subscription_state<T>(subscription: &crate::WsSubscription<T>, expected: crate::WsSubscriptionState) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
loop {
|
||||
if subscription.state() == expected {
|
||||
return;
|
||||
}
|
||||
assert!(tokio::time::Instant::now() < deadline, "subscription did not reach expected state: {expected:?}");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_generic_subscription_registers_remote_id_dispatches_typed_notification_and_unsubscribes() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(subscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("slotSubscribe"));
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(41)).await;
|
||||
send_notification(&mut websocket, "slotNotification", 41, serde_json::json!({"slot": 9001})).await;
|
||||
let unsubscribe = read_request(&mut websocket).await;
|
||||
assert_eq!(unsubscribe.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("slotUnsubscribe"));
|
||||
assert_eq!(unsubscribe.get("params"), std::option::Option::Some(&serde_json::json!([41])));
|
||||
send_result(&mut websocket, &unsubscribe, serde_json::json!(true)).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mut subscription = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("generic slot subscribe must succeed");
|
||||
assert_eq!(subscription.id().get(), 1);
|
||||
assert_eq!(subscription.kind(), crate::WsSubscriptionKind::Slot);
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Active);
|
||||
let snapshot = session.snapshot();
|
||||
assert_eq!(snapshot.subscription_count(), 1);
|
||||
assert_eq!(snapshot.subscriptions()[0].id(), subscription.id());
|
||||
assert!(snapshot.subscriptions()[0].remote_bound());
|
||||
let notification = subscription.recv().await.expect("typed notification channel must remain open").expect("notification must decode");
|
||||
assert_eq!(notification, serde_json::json!({"slot": 9001}));
|
||||
assert!(subscription.unsubscribe().await.expect("unsubscribe must succeed"));
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Closed);
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(session.snapshot().subscription_count(), 0);
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_remote_subscription_mapping_routes_multiple_families_to_stable_local_ids() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let first = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &first, serde_json::json!(77)).await;
|
||||
let second = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &second, serde_json::json!(12)).await;
|
||||
send_notification(&mut websocket, "logsNotification", 12, serde_json::json!({"family": "logs"})).await;
|
||||
send_notification(&mut websocket, "accountNotification", 77, serde_json::json!({"family": "account"})).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mut account = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Account, std::vec![serde_json::json!("account")], |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("account subscribe must succeed");
|
||||
let mut logs = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Logs, std::vec![serde_json::json!("all")], |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("logs subscribe must succeed");
|
||||
assert_eq!(account.id().get(), 1);
|
||||
assert_eq!(logs.id().get(), 2);
|
||||
assert_eq!(logs.recv().await.expect("logs notification must exist").expect("logs notification must decode"), serde_json::json!({"family": "logs"}));
|
||||
assert_eq!(
|
||||
account.recv().await.expect("account notification must exist").expect("account notification must decode"),
|
||||
serde_json::json!({"family": "account"})
|
||||
);
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_unknown_remote_subscription_notification_is_ignored_without_affecting_registered_subscription() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(3)).await;
|
||||
send_notification(&mut websocket, "rootNotification", 999, serde_json::json!(100)).await;
|
||||
send_notification(&mut websocket, "rootNotification", 3, serde_json::json!(101)).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mut subscription = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("root subscribe must succeed");
|
||||
let notification = subscription.recv().await.expect("valid notification must exist").expect("valid notification must decode");
|
||||
assert_eq!(notification, serde_json::json!(101));
|
||||
assert_eq!(subscription.state(), crate::WsSubscriptionState::Active);
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_notification_method_mismatch_fails_only_the_logical_subscription() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(5)).await;
|
||||
send_notification(&mut websocket, "rootNotification", 5, serde_json::json!(1)).await;
|
||||
let request = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &request, serde_json::json!(true)).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mut subscription = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Slot, std::vec::Vec::new(), |value| {
|
||||
return std::result::Result::Ok(value);
|
||||
})
|
||||
.await
|
||||
.expect("slot subscribe must succeed");
|
||||
wait_for_subscription_state(&subscription, crate::WsSubscriptionState::Failed).await;
|
||||
assert!(subscription.recv().await.is_none());
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
let result = session.execute_json_rpc("afterMismatch", std::vec::Vec::new()).await.expect("physical session must remain usable");
|
||||
assert_eq!(result, serde_json::json!(true));
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn websocket_typed_notification_decode_failure_fails_only_one_subscription_and_surfaces_error() {
|
||||
let (listener, url) = bind_local_listener().await;
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("local server must accept client");
|
||||
let mut websocket = tokio_tungstenite::accept_async(stream).await.expect("local WebSocket handshake must succeed");
|
||||
let subscribe = read_request(&mut websocket).await;
|
||||
send_result(&mut websocket, &subscribe, serde_json::json!(8)).await;
|
||||
send_notification(&mut websocket, "rootNotification", 8, serde_json::json!("not-a-slot")).await;
|
||||
});
|
||||
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||
let mut subscription = session
|
||||
.subscribe_typed(crate::WsSubscriptionKind::Root, std::vec::Vec::new(), |value| {
|
||||
return match value.as_u64() {
|
||||
std::option::Option::Some(slot) => std::result::Result::Ok(slot),
|
||||
std::option::Option::None => {
|
||||
std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_INVALID_RESPONSE, "fixture root notification must be numeric"))
|
||||
},
|
||||
};
|
||||
})
|
||||
.await
|
||||
.expect("root subscribe must succeed");
|
||||
let error = subscription.recv().await.expect("decode error must be delivered").expect_err("fixture payload must fail typed decoder");
|
||||
assert_eq!(error.code(), crate::ERROR_CODE_INVALID_RESPONSE);
|
||||
wait_for_subscription_state(&subscription, crate::WsSubscriptionState::Failed).await;
|
||||
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||
server.await.expect("local server task must complete");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user