v0.2.7-pre.006
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
# file: Cargo.toml
|
||||
# version: 198
|
||||
# version: 199
|
||||
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/ksp-app-config-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.7-pre.5"
|
||||
version = "0.2.7-pre.6"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
287
deltas/0.2.7/pre.006.md
Normal file
287
deltas/0.2.7/pre.006.md
Normal file
@@ -0,0 +1,287 @@
|
||||
<!-- file: deltas/0.2.7/pre.006.md -->
|
||||
<!-- version: 1 -->
|
||||
|
||||
# Delta `0.2.7-pre.006` — registry subscriptions + IDs locaux + moteur typed générique
|
||||
|
||||
## 1. Base requise
|
||||
|
||||
```text
|
||||
0.2.7-pre.005 appliqué
|
||||
workspace.package.version = 0.2.7-pre.5
|
||||
```
|
||||
|
||||
Le checkpoint opérateur reçu avant cette tranche est vert : `cargo fmt --all`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, `cargo test -p ksp-onchain-transport-lib` et `cargo test --workspace`. Les 269 tests unitaires Transport de `pre.005` passent.
|
||||
|
||||
## 2. Objectif
|
||||
|
||||
Cette tranche ajoute le registry de subscriptions au même actor que le socket et la pending map JSON-RPC, sans avancer sur reconnect/resubscribe.
|
||||
|
||||
Objectifs matérialisés :
|
||||
|
||||
```text
|
||||
1 session physique
|
||||
-> 0..N subscriptions logiques
|
||||
-> WsSubscriptionId local stable
|
||||
-> remote subscription id interne/transient
|
||||
-> channel de notifications typed et bounded
|
||||
```
|
||||
|
||||
La création générique de subscription reste crate-private jusqu'aux wrappers standard publics des lots `pre.009+`.
|
||||
|
||||
## 3. Signal technique
|
||||
|
||||
Cette prerelease modifie le runtime Rust, les tests et la documentation. Conformément aux règles KSP :
|
||||
|
||||
```text
|
||||
livraison = 0.2.7-pre.006
|
||||
workspace.package.version = 0.2.7-pre.6
|
||||
commit = v0.2.7-pre.006
|
||||
```
|
||||
|
||||
Aucun tag prerelease.
|
||||
|
||||
## 4. Registry actor-owned
|
||||
|
||||
Le même actor possède maintenant :
|
||||
|
||||
```text
|
||||
socket physique
|
||||
request counter
|
||||
pending JSON-RPC map
|
||||
subscription local counter
|
||||
subscription registry local
|
||||
remote_subscription_id -> WsSubscriptionId
|
||||
safe snapshots
|
||||
```
|
||||
|
||||
Le registry local est ordonné par local ID. Les IDs locaux sont assignés par l'actor et ne dépendent jamais du remote ID retourné par Solana.
|
||||
|
||||
Le remote ID reste absent de l'API publique, de `Debug`, des snapshots et des logs. Seul `remote_bound: bool` reste projeté par `WsSubscriptionSnapshot`.
|
||||
|
||||
## 5. Binding subscribe atomique
|
||||
|
||||
Le subscribe n'est pas implémenté comme :
|
||||
|
||||
```text
|
||||
execute_json_rpc
|
||||
puis register local
|
||||
```
|
||||
|
||||
car une notification pourrait alors arriver entre les deux opérations.
|
||||
|
||||
`pre.006` ajoute une command actor `Subscribe`. Le pending request conserve le local ID et le dispatcher typed. Lorsque la réponse `*Subscribe` est reçue :
|
||||
|
||||
1. la réponse JSON-RPC est validée ;
|
||||
2. le remote ID numérique est validé ;
|
||||
3. l'unicité du remote ID actif est vérifiée ;
|
||||
4. le remote ID est lié au local ID ;
|
||||
5. la subscription passe `Requested -> Active` ;
|
||||
6. seulement ensuite le handle typed est rendu au caller.
|
||||
|
||||
Le prochain message socket ne peut donc pas être dispatché avant la mise à jour du mapping actor-owned.
|
||||
|
||||
## 6. `WsSubscription<T>`
|
||||
|
||||
Nouvelle surface publique commune :
|
||||
|
||||
```text
|
||||
WsSubscription<T>
|
||||
id() -> WsSubscriptionId
|
||||
kind() -> WsSubscriptionKind
|
||||
state() -> WsSubscriptionState
|
||||
recv().await -> Option<Result<T>>
|
||||
unsubscribe().await -> Result<bool>
|
||||
```
|
||||
|
||||
Le receiver de notifications est borné par `notification_queue_capacity`.
|
||||
|
||||
La création `WsSession::subscribe_typed(...)` reste `pub(crate)` : elle sera consommée par les wrappers typed standard et ne crée pas de surface raw provider-extension publique.
|
||||
|
||||
## 7. Dispatch notifications
|
||||
|
||||
Pour une notification Solana standard :
|
||||
|
||||
```text
|
||||
JSON-RPC notification
|
||||
-> params.subscription remote u64
|
||||
-> remote_to_local
|
||||
-> WsSubscriptionRuntime
|
||||
-> validation notification method
|
||||
-> decoder typed
|
||||
-> bounded typed channel
|
||||
```
|
||||
|
||||
Le mapping exact famille/méthodes est centralisé sur `WsSubscriptionKind` pour les neuf familles :
|
||||
|
||||
```text
|
||||
account
|
||||
block
|
||||
logs
|
||||
program
|
||||
root
|
||||
signature
|
||||
slot
|
||||
slotsUpdates
|
||||
vote
|
||||
```
|
||||
|
||||
Chaque famille possède son triplet exact `*Subscribe`, `*Unsubscribe`, `*Notification`.
|
||||
|
||||
## 8. Anomalies isolées
|
||||
|
||||
Politique matérialisée dans cette tranche :
|
||||
|
||||
- remote ID inconnu/stale : safe drop + diagnostic sûr, session inchangée ;
|
||||
- notification method incompatible avec la famille enregistrée : subscription `Failed`, session reste `Active` ;
|
||||
- typed decoder failure : erreur livrée au channel lorsque possible, subscription `Failed`, session reste `Active` ;
|
||||
- queue typed déjà pleine : aucune perte silencieuse considérée normale, subscription terminale ; le compteur/cleanup adversarial complet est finalisé en `pre.008` ;
|
||||
- malformed JSON / structure JSON-RPC invalide : reste une anomalie de session selon le contrat acquis.
|
||||
|
||||
Aucun reconnect n'est déclenché par une erreur RPC applicative ou une erreur typed locale.
|
||||
|
||||
## 9. Unsubscribe
|
||||
|
||||
`WsSubscription<T>::unsubscribe()` ne reçoit jamais le remote ID du caller.
|
||||
|
||||
L'actor :
|
||||
|
||||
1. marque la subscription `Cancelling` ;
|
||||
2. retire immédiatement le remote ID du mapping de dispatch local ;
|
||||
3. construit le `*Unsubscribe` exact avec le remote ID interne ;
|
||||
4. valide la réponse booléenne ;
|
||||
5. publie `Closed` et ferme le channel local.
|
||||
|
||||
Le booléen standard Solana est préservé au caller afin de ne pas perdre une variante de réponse pertinente.
|
||||
|
||||
Les races unsubscribe/reconnect et le principe « local cancellation wins » sous reconnexion restent le scope explicite de `pre.007`.
|
||||
|
||||
## 10. Tests déterministes
|
||||
|
||||
Ajouts principaux :
|
||||
|
||||
```text
|
||||
subscribe slot -> remote binding -> notification typed -> unsubscribe exact
|
||||
2 familles -> 2 IDs locaux ordonnés + remote IDs indépendants
|
||||
unknown remote ID -> notification suivante valide toujours dispatchée
|
||||
notification method mismatch -> seule la subscription échoue
|
||||
typed decode error -> erreur receiver + seule la subscription échoue
|
||||
triplets exacts des 9 familles standard
|
||||
public API canary WsSubscription<T>
|
||||
```
|
||||
|
||||
Le serveur reste exclusivement local et déterministe. Aucun réseau Solana réel n'est requis.
|
||||
|
||||
## 11. Logging et sécurité
|
||||
|
||||
Toutes les émissions passent exclusivement par `ksp-logging-lib` avec :
|
||||
|
||||
```text
|
||||
TRACING_TARGET = "ksp-onchain-transport-lib"
|
||||
```
|
||||
|
||||
Les diagnostics n'exposent que :
|
||||
|
||||
```text
|
||||
session_id
|
||||
subscription_id local
|
||||
subscription_kind
|
||||
endpoint logique
|
||||
counts/états sûrs
|
||||
```
|
||||
|
||||
Aucun remote subscription ID n'est journalisé comme identité métier, et aucune URL, credential ou notification brute n'est loggée.
|
||||
|
||||
## 12. Fichiers ajoutés
|
||||
|
||||
```text
|
||||
crates/ksp-onchain-transport-lib/src/ws_subscription.rs
|
||||
deltas/0.2.7/pre.006.md
|
||||
```
|
||||
|
||||
## 13. Fichiers modifiés
|
||||
|
||||
```text
|
||||
Cargo.toml
|
||||
crates/ksp-onchain-transport-lib/README.md
|
||||
crates/ksp-onchain-transport-lib/USAGE.md
|
||||
crates/ksp-onchain-transport-lib/src/lib.rs
|
||||
crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||
crates/ksp-onchain-transport-lib/src/ws_session.rs
|
||||
crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/ws_lifecycle.rs
|
||||
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
|
||||
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||
```
|
||||
|
||||
## 14. Fichiers supprimés
|
||||
|
||||
Aucun.
|
||||
|
||||
`ROADMAP.md` et `CHANGELOG.md` restent inchangés pendant la série prerelease.
|
||||
|
||||
## 15. Validation exécutée dans le sandbox
|
||||
|
||||
```text
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
General Rust rule audit: clean
|
||||
Rust export completeness audit: 0 candidate(s)
|
||||
KSP workspace Rust rule audit: clean
|
||||
```
|
||||
|
||||
Contrôles statiques supplémentaires :
|
||||
|
||||
```text
|
||||
workspace.package.version = 0.2.7-pre.6
|
||||
tracing direct = absent
|
||||
question-mark runtime = absent
|
||||
remote ID public = absent
|
||||
lignes Rust > 160 ajoutées = absentes
|
||||
```
|
||||
|
||||
## 16. Validation non exécutée dans le sandbox
|
||||
|
||||
Cargo/rustfmt ne sont pas disponibles dans le sandbox de génération. Aucun résultat local n'est revendiqué pour :
|
||||
|
||||
```text
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
## 17. Gates opérateur avant commit
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
python3 scripts/audit_rust_workspace_rules.py
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace --all-targets
|
||||
cargo test -p ksp-onchain-transport-lib
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
Si le checkpoint est vert :
|
||||
|
||||
```text
|
||||
commit = v0.2.7-pre.006
|
||||
```
|
||||
|
||||
La tranche suivante est `0.2.7-pre.007` : reconnect borné, resubscribe déterministe, continuity gap et races unsubscribe/reconnect.
|
||||
|
||||
## 18. Décisions / questions ouvertes
|
||||
|
||||
Décisions :
|
||||
|
||||
- le registry et le mapping remote/local appartiennent exclusivement à l'actor ;
|
||||
- le local ID est stable et le remote ID est transient/interne ;
|
||||
- la création générique typed reste crate-private ;
|
||||
- le handle typed public possède le receiver et l'unsubscribe ;
|
||||
- une anomalie typed connue ne doit pas faire tomber la session physique ;
|
||||
- aucune promesse lossless n'est introduite.
|
||||
|
||||
Questions laissées aux tranches suivantes :
|
||||
|
||||
- `pre.007` finalise la restauration déterministe après reconnexion et les races cancellation/ACK ;
|
||||
- `pre.008` finalise overflow counters, cleanup best-effort et leak/lifecycle adversarial sous slow consumer.
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 8 -->
|
||||
|
||||
# Plan `0.2.7` — WebSocket Solana standard
|
||||
|
||||
@@ -486,7 +486,9 @@ Une tâche actor possède exclusivement :
|
||||
|
||||
Le handle public `WsSession` communique avec cet actor par canal bounded. Aucun caller ne split/manipule directement le socket.
|
||||
|
||||
`pre.004` matérialise cette foundation : `WsSession::connect` ouvre une connexion physique explicite, le socket reste exclusivement dans l'actor, les commandes passent par `mpsc` borné, les réponses JSON-RPC sont dispatchées par ID KSP dans une map bornée et les snapshots sûrs sont publiés par `watch`. La primitive JSON-RPC reste `pub(crate)` afin de ne pas créer une API publique raw provider-extension avant les wrappers typed. Le registry des subscriptions et le mapping remote/local restent `pre.006`.
|
||||
`pre.004` matérialise cette foundation : `WsSession::connect` ouvre une connexion physique explicite, le socket reste exclusivement dans l'actor, les commandes passent par `mpsc` borné, les réponses JSON-RPC sont dispatchées par ID KSP dans une map bornée et les snapshots sûrs sont publiés par `watch`. La primitive JSON-RPC reste `pub(crate)` afin de ne pas créer une API publique raw provider-extension avant les wrappers typed. Le registry des subscriptions et le mapping remote/local étaient réservés à `pre.006`.
|
||||
|
||||
`pre.006` matérialise le registry dans ce même actor. L'ACK `*Subscribe` lie atomiquement le remote ID au `WsSubscriptionId` local avant le dispatch des notifications suivantes. La création générique typed reste crate-private ; `WsSubscription<T>` devient le handle public commun pour `id/state/recv/unsubscribe`. Les channels de notification sont bornés et typed via un dispatcher actor type-erased. Les remote IDs restent absents de l'API publique et des snapshots.
|
||||
|
||||
### 9.4 Identités
|
||||
|
||||
@@ -876,7 +878,7 @@ pre.002 DONE — settings WS Transport + URL redaction + IDs/states/snapshots +
|
||||
pre.003 DONE — std.transport V2 HTTP+WS + backward V1 + discriminateur WS + schema/fixtures + Config -> WsTransportSettings
|
||||
pre.004 DONE — deps tokio-tungstenite/futures-util + actor physique + handshake/read/write + pending JSON-RPC + serveur local
|
||||
pre.005 DONE — limites frame/message/request + control frames + cancellation/close/shutdown + adversarial socket tests
|
||||
pre.006 registry subscriptions + IDs locaux + generic subscribe/unsubscribe engine + channels typed bounded
|
||||
pre.006 DONE — registry subscriptions + IDs locaux + generic subscribe/unsubscribe engine + channels typed bounded
|
||||
pre.007 reconnect borné + resubscribe déterministe + continuity gap + races unsubscribe/reconnect
|
||||
pre.008 backpressure per-sub + overflow/limits + leak/lifecycle adversarial tests
|
||||
pre.009 wrappers stable lot A : account + program + logs, DTOs/options/KSP-TRANSPORT-007
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- file: docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md -->
|
||||
<!-- version: 7 -->
|
||||
<!-- version: 8 -->
|
||||
|
||||
# Validation `0.2.7` — WebSocket Solana standard
|
||||
|
||||
@@ -377,6 +377,39 @@ cycles connect/close répétés -> terminaison bornée
|
||||
|
||||
Le signal de shutdown est indépendant de la command queue et les opérations socket longues de l'actor surveillent ce signal. Aucun reconnect/resubscribe n'est activé par cette tranche.
|
||||
|
||||
## 9.4 Checkpoint registry subscriptions `pre.006`
|
||||
|
||||
Surface matérialisée :
|
||||
|
||||
```text
|
||||
WsSubscription<T> handle public typed
|
||||
WsSubscriptionId local stable, actor-assigned
|
||||
remote subscription id interne/transient uniquement
|
||||
registry local BTreeMap ordonnée par local ID
|
||||
remote -> local mapping actor-owned
|
||||
subscribe generic crate-private, wrappers publics futurs
|
||||
unsubscribe handle public, bool Solana préservé
|
||||
notification queue typed + bounded
|
||||
reconnect/resubscribe non, pre.007
|
||||
backpressure adversarial complet non, pre.008
|
||||
```
|
||||
|
||||
Gates déterministes ajoutés :
|
||||
|
||||
```text
|
||||
subscribe ACK -> binding remote/local atomique
|
||||
notification -> bonne subscription typed
|
||||
2 familles -> IDs locaux 1 puis 2, remote IDs indépendants
|
||||
unknown/stale remote ID -> safe drop, session Active
|
||||
notification method mismatch -> subscription Failed seulement
|
||||
typed decoder failure -> erreur livrée + subscription Failed seulement
|
||||
unsubscribe -> exact *Unsubscribe avec remote ID interne
|
||||
unsubscribe bool -> préservé au caller
|
||||
snapshot -> local IDs + remote_bound uniquement
|
||||
```
|
||||
|
||||
Le registry est détenu par le même actor que le socket et la pending map JSON-RPC. Aucun caller ne manipule le socket ni le remote ID. Les wrappers publics `accountSubscribe`, `programSubscribe`, etc. restent volontairement différés aux lots `pre.009+` afin de ne pas exposer une API raw provider-extension intermédiaire.
|
||||
|
||||
## 10. Validation du gate `pre.001`
|
||||
|
||||
Exécuté dans le sandbox :
|
||||
|
||||
Reference in New Issue
Block a user