v0.2.7-pre.006

This commit is contained in:
2026-08-22 19:56:20 +02:00
parent 6e3a0fa034
commit 8721e54b18
13 changed files with 1576 additions and 191 deletions

View File

@@ -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"

View File

@@ -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 :

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View 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());
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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
View 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.

View File

@@ -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

View File

@@ -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 :