v0.2.7-pre.004
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
# file: Cargo.toml
|
# file: Cargo.toml
|
||||||
# version: 195
|
# version: 196
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
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"]
|
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]
|
[workspace.package]
|
||||||
version = "0.2.7-pre.3"
|
version = "0.2.7-pre.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
|
||||||
@@ -21,6 +21,7 @@ ed25519-dalek = { version = "^3.0", default-features = false }
|
|||||||
getrandom = { version = "^0.4", default-features = false }
|
getrandom = { version = "^0.4", default-features = false }
|
||||||
base64 = { version = "^0.23" }
|
base64 = { version = "^0.23" }
|
||||||
fs2 = { version = "^0.4" }
|
fs2 = { version = "^0.4" }
|
||||||
|
futures-util = { version = "^0.3", default-features = false }
|
||||||
serde = { version = "^1.0" }
|
serde = { version = "^1.0" }
|
||||||
serde_json = { version = "^1.0" }
|
serde_json = { version = "^1.0" }
|
||||||
jsonschema = { version = "^0.50", default-features = false }
|
jsonschema = { version = "^0.50", default-features = false }
|
||||||
@@ -31,6 +32,7 @@ tracing = { version = "^0.1", default-features = false }
|
|||||||
tracing-subscriber = { version = "^0.3", default-features = false }
|
tracing-subscriber = { version = "^0.3", default-features = false }
|
||||||
tracing-appender = { version = "^0.2", default-features = false }
|
tracing-appender = { version = "^0.2", default-features = false }
|
||||||
tokio = { version = "^1.53", default-features = false }
|
tokio = { version = "^1.53", default-features = false }
|
||||||
|
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||||
tempfile = { version = "^3.27" }
|
tempfile = { version = "^3.27" }
|
||||||
chrono = { version = "^0.4", default-features = false }
|
chrono = { version = "^0.4", default-features = false }
|
||||||
tauri = { version = "^2.11" }
|
tauri = { version = "^2.11" }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
|
// file: crates/ksp-core-lib/tests/workspace_dependencies.rs
|
||||||
// version: 1
|
// version: 2
|
||||||
|
|
||||||
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
|
//! Workspace-level dependency policy canaries owned by the foundational KSP test surface.
|
||||||
|
|
||||||
@@ -61,8 +61,10 @@ fn transport_manifest_preserves_ksp_dependency_firewall() {
|
|||||||
}
|
}
|
||||||
assert!(manifest.contains("ksp-core-lib"));
|
assert!(manifest.contains("ksp-core-lib"));
|
||||||
assert!(manifest.contains("ksp-logging-lib"));
|
assert!(manifest.contains("ksp-logging-lib"));
|
||||||
|
assert!(manifest.contains("futures-util = { workspace = true, features = [\"sink\", \"std\"] }"));
|
||||||
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
assert!(manifest.contains("reqwest = { workspace = true, features = [\"rustls\"] }"));
|
||||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"sync\", \"time\"] }"));
|
assert!(manifest.contains("tokio = { workspace = true, features = [\"macros\", \"rt\", \"sync\", \"time\"] }"));
|
||||||
|
assert!(manifest.contains("tokio-tungstenite = { workspace = true, features = [\"connect\", \"rustls-tls-webpki-roots\"] }"));
|
||||||
assert!(manifest.contains("[dev-dependencies]"));
|
assert!(manifest.contains("[dev-dependencies]"));
|
||||||
assert!(manifest.contains("tokio = { workspace = true, features = [\"rt\"] }"));
|
assert!(manifest.contains("tokio = { workspace = true, features = [\"net\", \"rt\"] }"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# file: crates/ksp-onchain-transport-lib/Cargo.toml
|
# file: crates/ksp-onchain-transport-lib/Cargo.toml
|
||||||
# version: 3
|
# version: 4
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "ksp-onchain-transport-lib"
|
name = "ksp-onchain-transport-lib"
|
||||||
@@ -10,13 +10,15 @@ repository.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ksp-core-lib = { path = "../ksp-core-lib" }
|
ksp-core-lib = { path = "../ksp-core-lib" }
|
||||||
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
ksp-logging-lib = { path = "../ksp-logging-lib" }
|
||||||
|
futures-util = { workspace = true, features = ["sink", "std"] }
|
||||||
reqwest = { workspace = true, features = ["rustls"] }
|
reqwest = { workspace = true, features = ["rustls"] }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio = { workspace = true, features = ["macros", "sync", "time"] }
|
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
|
||||||
|
tokio-tungstenite = { workspace = true, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt"] }
|
tokio = { workspace = true, features = ["net", "rt"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
<!-- file: crates/ksp-onchain-transport-lib/README.md -->
|
||||||
<!-- version: 9 -->
|
<!-- version: 10 -->
|
||||||
|
|
||||||
# `ksp-onchain-transport-lib`
|
# `ksp-onchain-transport-lib`
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ ksp-config-lib
|
|||||||
-> ksp-core-lib
|
-> ksp-core-lib
|
||||||
-> ksp-logging-lib
|
-> ksp-logging-lib
|
||||||
-> reqwest / tokio / serde
|
-> reqwest / tokio / serde
|
||||||
|
-> tokio-tungstenite / futures-util
|
||||||
```
|
```
|
||||||
|
|
||||||
La direction inverse est interdite :
|
La direction inverse est interdite :
|
||||||
@@ -85,6 +86,25 @@ total : 52
|
|||||||
|
|
||||||
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
|
Les 14 méthodes historiques restent découvrables pour la compliance mais sont `Removed` et ne sont pas simulées comme appelables.
|
||||||
|
|
||||||
|
## Foundation WebSocket `0.2.7-pre.004`
|
||||||
|
|
||||||
|
La première session physique WebSocket est matérialisée sans introduire de pool/scheduler automatique ni de registry de subscriptions anticipé.
|
||||||
|
|
||||||
|
`WsSession::connect(WsEndpointSettings)` :
|
||||||
|
|
||||||
|
- ouvre exactement une connexion physique pour un appel ;
|
||||||
|
- confie le socket à une tâche actor unique ;
|
||||||
|
- sérialise les commandes internes par un canal `mpsc` borné ;
|
||||||
|
- maintient une map bornée de requests JSON-RPC en attente ;
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Résilience
|
## Résilience
|
||||||
|
|
||||||
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
L'admission est calculée par couple endpoint/rôle. Le pool applique :
|
||||||
@@ -148,4 +168,4 @@ Les deux sont `ignored` par défaut. Le smoke Transport appartient durablement
|
|||||||
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée `0.2.3` ;
|
- [`../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md`](../../docs/validation/006-V0_2_3_HTTP_TRANSACTIONS.md) — matrice finale validée `0.2.3` ;
|
||||||
- [`../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) — plan Blocks/Economics et compliance HTTP finale ;
|
- [`../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md`](../../docs/plans/011-V0_2_4_HTTP_BLOCKS_ECONOMICS_PLAN.md) — plan Blocks/Economics et compliance HTTP finale ;
|
||||||
- [`../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée `52/52 + 14/14` et audit `KSP-TRANSPORT-007` global ;
|
- [`../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md`](../../docs/validation/007-V0_2_4_HTTP_FINAL_COMPLIANCE.md) — matrice finale validée `52/52 + 14/14` et audit `KSP-TRANSPORT-007` global ;
|
||||||
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP.
|
- [`../../config/std.transport.json`](../../config/std.transport.json) — configuration standard HTTP + WebSocket V2, avec lecture backward V1 HTTP-only.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
<!-- file: crates/ksp-onchain-transport-lib/USAGE.md -->
|
||||||
<!-- version: 9 -->
|
<!-- version: 10 -->
|
||||||
|
|
||||||
# Utilisation de `ksp-onchain-transport-lib`
|
# Utilisation de `ksp-onchain-transport-lib`
|
||||||
|
|
||||||
@@ -65,7 +65,38 @@ let pool = match ksp_onchain_transport_lib::HttpTransportPool::new(resolved.into
|
|||||||
|
|
||||||
Le document standard peut contenir une URL provenant d'un `KSP_SECRET_*`. La valeur réelle est transmise au runtime, mais les projections sûres et `Debug` restent redacted.
|
Le document standard peut contenir une URL provenant d'un `KSP_SECRET_*`. La valeur réelle est transmise au runtime, mais les projections sûres et `Debug` restent redacted.
|
||||||
|
|
||||||
## 3. Appels typés
|
## 3. Session physique WebSocket
|
||||||
|
|
||||||
|
À partir de `0.2.7-pre.004`, un consumer peut créer explicitement une session physique :
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let ws_url = match ksp_onchain_transport_lib::WsEndpointUrl::parse("wss://api.devnet.solana.com") {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
let endpoint = ksp_onchain_transport_lib::WsEndpointSettings::new(
|
||||||
|
"devnet_public",
|
||||||
|
true,
|
||||||
|
ksp_onchain_transport_lib::WsProviderName::new("solana-public"),
|
||||||
|
ksp_onchain_transport_lib::WsClusterName::new("devnet"),
|
||||||
|
ksp_onchain_transport_lib::WsProtocolKind::SolanaStandard,
|
||||||
|
ws_url,
|
||||||
|
ksp_onchain_transport_lib::WsSessionSettings::default(),
|
||||||
|
);
|
||||||
|
let session = match ksp_onchain_transport_lib::WsSession::connect(endpoint).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
## 4. Appels typés
|
||||||
|
|
||||||
Les wrappers typés se trouvent directement sur `HttpTransportPool`.
|
Les wrappers typés se trouvent directement sur `HttpTransportPool`.
|
||||||
|
|
||||||
@@ -125,7 +156,7 @@ let stake_minimum = pool.get_stake_minimum_delegation(&role, Some(&context)).awa
|
|||||||
|
|
||||||
`getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant.
|
`getBlock` possède également une forme bare-encoding legacy séparée et deprecated. Les valeurs Economics restent celles du runtime : le consumer ne doit pas supposer localement un taux d'inflation ou un minimum de délégation constant.
|
||||||
|
|
||||||
## 4. Exécution JSON-RPC standard générique
|
## 5. Exécution JSON-RPC standard générique
|
||||||
|
|
||||||
Une méthode courante auditée peut être appelée via son descriptor :
|
Une méthode courante auditée peut être appelée via son descriptor :
|
||||||
|
|
||||||
@@ -139,7 +170,7 @@ Cette API retourne un `serde_json::Value`. Elle reste utile pour les extensions
|
|||||||
|
|
||||||
Avant exécution, `ensure_runtime_supported()` est appliqué. Une méthode historique `Removed` retourne `ERROR_CODE_METHOD_REMOVED` au lieu d'émettre un appel réseau fictif.
|
Avant exécution, `ensure_runtime_supported()` est appliqué. Une méthode historique `Removed` retourne `ERROR_CODE_METHOD_REMOVED` au lieu d'émettre un appel réseau fictif.
|
||||||
|
|
||||||
## 5. Sélection et admission sans exécuter la requête
|
## 6. Sélection et admission sans exécuter la requête
|
||||||
|
|
||||||
Pour inspecter le routing :
|
Pour inspecter le routing :
|
||||||
|
|
||||||
@@ -154,13 +185,13 @@ Dans le même bloc, `acquire_for_method()` réserve réellement la capacité RPS
|
|||||||
|
|
||||||
`HttpRequestPermit` détient la capacité de concurrence jusqu'à sa destruction. Aucun verrou synchrone n'est conservé pendant l'attente réseau.
|
`HttpRequestPermit` détient la capacité de concurrence jusqu'à sa destruction. Aucun verrou synchrone n'est conservé pendant l'attente réseau.
|
||||||
|
|
||||||
## 6. Snapshots runtime
|
## 7. Snapshots runtime
|
||||||
|
|
||||||
`HttpTransportPool::snapshot()` fournit une vue sûre des endpoints/rôles : disponibilité, limites, requêtes en vol, cooldown restant et compteurs runtime.
|
`HttpTransportPool::snapshot()` fournit une vue sûre des endpoints/rôles : disponibilité, limites, requêtes en vol, cooldown restant et compteurs runtime.
|
||||||
|
|
||||||
Les URLs d'endpoint n'y apparaissent jamais.
|
Les URLs d'endpoint n'y apparaissent jamais.
|
||||||
|
|
||||||
## 7. Retry et write submissions
|
## 8. Retry et write submissions
|
||||||
|
|
||||||
La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`.
|
La policy de retry est portée par la metadata des méthodes et `evaluate_transport_retry()`.
|
||||||
|
|
||||||
@@ -168,7 +199,7 @@ Les reads/simulations classés `RetrySafe` peuvent être réessayés dans le bud
|
|||||||
|
|
||||||
Pour une opération `WriteSubmission / NeverAfterDispatch`, un timeout ou autre résultat ambigu après dispatch arrête la resoumission automatique. Le consumer métier ne doit pas contourner cette protection avec une boucle de retry externe aveugle.
|
Pour une opération `WriteSubmission / NeverAfterDispatch`, un timeout ou autre résultat ambigu après dispatch arrête la resoumission automatique. Le consumer métier ne doit pas contourner cette protection avec une boucle de retry externe aveugle.
|
||||||
|
|
||||||
## 8. Logging
|
## 9. Logging
|
||||||
|
|
||||||
Les événements Transport utilisent le target :
|
Les événements Transport utilisent le target :
|
||||||
|
|
||||||
@@ -180,7 +211,7 @@ Ne jamais journaliser l'URL complète, un token provider, un body massif, une tr
|
|||||||
|
|
||||||
La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement.
|
La configuration standard route les événements `info` de Transport vers un fichier dédié. Pour une investigation temporaire, élever uniquement ce target/sink à `debug` ou `trace`, puis revenir à `info` avant clôture du développement.
|
||||||
|
|
||||||
## 9. Smokes Devnet opt-in
|
## 10. Smokes Devnet opt-in
|
||||||
|
|
||||||
Le smoke **Transport pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif d'Accounts/Tokens/Cluster, trois reads Transactions, puis des reads Blocks/Economics de la release stable `0.2.4` :
|
Le smoke **Transport pur** construit ses settings programmatiquement et exerce un sous-ensemble représentatif d'Accounts/Tokens/Cluster, trois reads Transactions, puis des reads Blocks/Economics de la release stable `0.2.4` :
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
// file: crates/ksp-onchain-transport-lib/src/error.rs
|
||||||
// version: 3
|
// version: 4
|
||||||
|
|
||||||
/// Error code used when no logical endpoint can satisfy a request.
|
/// Error code used when no logical endpoint can satisfy a request.
|
||||||
pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
|
pub const ERROR_CODE_ENDPOINT_SELECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "endpoint_selection_failed");
|
||||||
@@ -27,3 +27,11 @@ pub const ERROR_CODE_RATE_LIMITED: ksp_core_lib::ErrorCode = ksp_core_lib::Error
|
|||||||
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
|
pub const ERROR_CODE_RPC_APPLICATION_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "rpc_application_error");
|
||||||
/// Error code used when a transport deadline expires.
|
/// Error code used when a transport deadline expires.
|
||||||
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
pub const ERROR_CODE_TIMEOUT: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "timeout");
|
||||||
|
/// Error code used when a bounded WebSocket runtime queue or pending-request capacity is exhausted.
|
||||||
|
pub const ERROR_CODE_WS_BACKPRESSURE_OVERFLOW: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_backpressure_overflow");
|
||||||
|
/// Error code used when a physical WebSocket connection or handshake fails.
|
||||||
|
pub const ERROR_CODE_WS_CONNECTION_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_connection_failed");
|
||||||
|
/// Error code used when WebSocket wire data violates the KSP protocol contract.
|
||||||
|
pub const ERROR_CODE_WS_PROTOCOL_ERROR: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_protocol_error");
|
||||||
|
/// Error code used when a WebSocket session is no longer available to a caller.
|
||||||
|
pub const ERROR_CODE_WS_SESSION_CLOSED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("onchain_transport", "ws_session_closed");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
// file: crates/ksp-onchain-transport-lib/src/lib.rs
|
||||||
// version: 22
|
// version: 23
|
||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
#![deny(unreachable_pub)]
|
#![deny(unreachable_pub)]
|
||||||
@@ -17,7 +17,8 @@
|
|||||||
//! modern/legacy `getBlock`, positional inflation rewards, runtime-provided economics values and the final `KSP-TRANSPORT-007` compliance target.
|
//! modern/legacy `getBlock`, positional inflation rewards, runtime-provided economics values and the final `KSP-TRANSPORT-007` compliance target.
|
||||||
//! The candidate surface therefore exposes typed wrappers for all 52 current audited Solana HTTP methods while retaining 14 removed historical descriptors.
|
//! The candidate surface therefore exposes typed wrappers for all 52 current audited Solana HTTP methods while retaining 14 removed historical descriptors.
|
||||||
//! `0.2.7-pre.002` adds the provider-neutral WebSocket settings foundation, redacted endpoint URLs, explicit protocol-family discrimination, local session/subscription
|
//! `0.2.7-pre.002` adds the provider-neutral WebSocket settings foundation, redacted endpoint URLs, explicit protocol-family discrimination, local session/subscription
|
||||||
//! identities, observable lifecycle states and safe snapshots. Physical sockets and subscription execution are intentionally deferred to later prereleases.
|
//! identities, observable lifecycle states and safe snapshots. `0.2.7-pre.004` adds the first physical WebSocket runtime: bounded handshake, one actor-owned socket,
|
||||||
|
//! bounded command/pending JSON-RPC flow, safe session snapshots and deterministic local-server fixtures. Subscription registration remains deferred.
|
||||||
|
|
||||||
mod client;
|
mod client;
|
||||||
mod constants;
|
mod constants;
|
||||||
@@ -37,6 +38,7 @@ mod rpc_tokens;
|
|||||||
mod rpc_transactions;
|
mod rpc_transactions;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod ws_lifecycle;
|
mod ws_lifecycle;
|
||||||
|
mod ws_session;
|
||||||
mod ws_settings;
|
mod ws_settings;
|
||||||
|
|
||||||
/// Passive runtime availability reported for one logical HTTP endpoint.
|
/// Passive runtime availability reported for one logical HTTP endpoint.
|
||||||
@@ -73,6 +75,14 @@ pub use self::error::ERROR_CODE_RATE_LIMITED;
|
|||||||
pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR;
|
pub use self::error::ERROR_CODE_RPC_APPLICATION_ERROR;
|
||||||
/// Error code used when a transport deadline expires.
|
/// Error code used when a transport deadline expires.
|
||||||
pub use self::error::ERROR_CODE_TIMEOUT;
|
pub use self::error::ERROR_CODE_TIMEOUT;
|
||||||
|
/// Error code used when bounded WebSocket runtime capacity is exhausted.
|
||||||
|
pub use self::error::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW;
|
||||||
|
/// Error code used when a physical WebSocket connection or handshake fails.
|
||||||
|
pub use self::error::ERROR_CODE_WS_CONNECTION_FAILED;
|
||||||
|
/// Error code used when WebSocket wire data violates protocol invariants.
|
||||||
|
pub use self::error::ERROR_CODE_WS_PROTOCOL_ERROR;
|
||||||
|
/// Error code used when a WebSocket session is no longer available.
|
||||||
|
pub use self::error::ERROR_CODE_WS_SESSION_CLOSED;
|
||||||
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
/// JSON-RPC 2.0 error payload returned by a remote Solana endpoint.
|
||||||
pub use self::json_rpc::JsonRpcErrorObject;
|
pub use self::json_rpc::JsonRpcErrorObject;
|
||||||
/// Validated JSON-RPC 2.0 error response.
|
/// Validated JSON-RPC 2.0 error response.
|
||||||
@@ -309,6 +319,8 @@ pub use self::ws_lifecycle::WsSubscriptionKind;
|
|||||||
pub use self::ws_lifecycle::WsSubscriptionSnapshot;
|
pub use self::ws_lifecycle::WsSubscriptionSnapshot;
|
||||||
/// Observable lifecycle state of one logical WebSocket subscription.
|
/// Observable lifecycle state of one logical WebSocket subscription.
|
||||||
pub use self::ws_lifecycle::WsSubscriptionState;
|
pub use self::ws_lifecycle::WsSubscriptionState;
|
||||||
|
/// Shareable handle for one explicitly created physical WebSocket session.
|
||||||
|
pub use self::ws_session::WsSession;
|
||||||
/// Open cluster or network descriptor used by WebSocket endpoint settings.
|
/// Open cluster or network descriptor used by WebSocket endpoint settings.
|
||||||
pub use self::ws_settings::WsClusterName;
|
pub use self::ws_settings::WsClusterName;
|
||||||
/// Runtime settings for one named WebSocket endpoint.
|
/// Runtime settings for one named WebSocket endpoint.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
// file: crates/ksp-onchain-transport-lib/src/ws_lifecycle.rs
|
||||||
// version: 2
|
// version: 3
|
||||||
|
|
||||||
/// Stable local identity assigned to one physical WebSocket session.
|
/// Stable local identity assigned to one physical WebSocket session.
|
||||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
@@ -183,7 +183,6 @@ pub struct WsSessionSnapshot {
|
|||||||
impl WsSessionSnapshot {
|
impl WsSessionSnapshot {
|
||||||
/// Creates one safe session projection for Transport runtime internals.
|
/// Creates one safe session projection for Transport runtime internals.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
#[cfg(test)]
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
id: crate::WsSessionId,
|
id: crate::WsSessionId,
|
||||||
|
|||||||
574
crates/ksp-onchain-transport-lib/src/ws_session.rs
Normal file
574
crates/ksp-onchain-transport-lib/src/ws_session.rs
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/src/ws_session.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
use futures_util::SinkExt; // rust-rules: trait-import
|
||||||
|
use futures_util::StreamExt; // rust-rules: trait-import
|
||||||
|
|
||||||
|
static NEXT_WS_SESSION_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||||
|
|
||||||
|
/// Shareable handle for one explicitly created physical WebSocket session.
|
||||||
|
///
|
||||||
|
/// The handle never exposes the sensitive endpoint URL or the underlying socket. All socket I/O is owned by one internal actor task and all caller
|
||||||
|
/// interaction is serialized through a bounded command queue.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct WsSession {
|
||||||
|
id: crate::WsSessionId,
|
||||||
|
command_tx: tokio::sync::mpsc::Sender<WsSessionCommand>,
|
||||||
|
snapshot_rx: tokio::sync::watch::Receiver<crate::WsSessionSnapshot>,
|
||||||
|
command_timeout: std::time::Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WsSession {
|
||||||
|
/// Opens one physical WebSocket connection for the supplied endpoint settings.
|
||||||
|
///
|
||||||
|
/// Calling this function twice with the same endpoint creates two independent physical sessions. The function returns only after the WebSocket
|
||||||
|
/// handshake succeeds or the configured command timeout expires.
|
||||||
|
pub async fn connect(endpoint: crate::WsEndpointSettings) -> ksp_core_lib::Result<Self> {
|
||||||
|
let validation = crate::WsTransportSettings::new(std::vec![endpoint.clone()]).validate();
|
||||||
|
if let std::result::Result::Err(error) = validation {
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
}
|
||||||
|
let id_result = next_session_id();
|
||||||
|
let id = match id_result {
|
||||||
|
std::result::Result::Ok(id) => id,
|
||||||
|
std::result::Result::Err(error) => return std::result::Result::Err(error),
|
||||||
|
};
|
||||||
|
let initial_snapshot = crate::WsSessionSnapshot::new(
|
||||||
|
id,
|
||||||
|
endpoint.name(),
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.protocol(),
|
||||||
|
crate::WsSessionState::Connecting,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
);
|
||||||
|
let (command_tx, command_rx) = tokio::sync::mpsc::channel(endpoint.session().command_queue_capacity());
|
||||||
|
let (snapshot_tx, snapshot_rx) = tokio::sync::watch::channel(initial_snapshot);
|
||||||
|
let (startup_tx, startup_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let command_timeout = endpoint.session().command_timeout();
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
endpoint_name = endpoint.name(),
|
||||||
|
provider = endpoint.provider().as_str(),
|
||||||
|
cluster = endpoint.cluster().as_str(),
|
||||||
|
protocol = endpoint.protocol().as_str(),
|
||||||
|
"starting physical WebSocket session actor"
|
||||||
|
);
|
||||||
|
let join_handle = tokio::spawn(run_ws_session_actor(id, endpoint, command_rx, snapshot_tx, startup_tx));
|
||||||
|
let startup_wait = tokio::time::timeout(command_timeout, startup_rx).await;
|
||||||
|
match startup_wait {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(std::result::Result::Ok(()))) => {
|
||||||
|
return std::result::Result::Ok(Self { id, command_tx, snapshot_rx, command_timeout });
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(std::result::Result::Err(error))) => {
|
||||||
|
join_handle.abort();
|
||||||
|
return std::result::Result::Err(error);
|
||||||
|
},
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(_)) => {
|
||||||
|
join_handle.abort();
|
||||||
|
return std::result::Result::Err(ws_session_closed_error(id, "WebSocket session actor ended during startup"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
join_handle.abort();
|
||||||
|
return std::result::Result::Err(ws_timeout_error(id, "WebSocket handshake exceeded the configured command timeout"));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stable local session identity.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn id(&self) -> crate::WsSessionId {
|
||||||
|
return self.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the latest safe runtime snapshot published by the actor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn snapshot(&self) -> crate::WsSessionSnapshot {
|
||||||
|
return self.snapshot_rx.borrow().clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the latest observable physical-session state.
|
||||||
|
#[must_use]
|
||||||
|
pub fn state(&self) -> crate::WsSessionState {
|
||||||
|
return self.snapshot_rx.borrow().state();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes one internal JSON-RPC request through the actor-owned socket.
|
||||||
|
///
|
||||||
|
/// This remains crate-private in `0.2.7`; standard subscriptions consume it without exposing a public raw provider-extension escape hatch.
|
||||||
|
#[allow(dead_code)] // Staged in pre.004 and consumed by the subscription engine starting in pre.006.
|
||||||
|
pub(crate) async fn execute_json_rpc(&self, method: &'static str, params: std::vec::Vec<serde_json::Value>) -> ksp_core_lib::Result<serde_json::Value> {
|
||||||
|
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let command = WsSessionCommand::ExecuteJsonRpc { method, params, 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(ws_session_closed_error(self.id, "WebSocket session command channel is closed"));
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ws_timeout_error(self.id, "WebSocket session command queue remained unavailable until timeout"));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
let response_wait = tokio::time::timeout(self.command_timeout, response_rx).await;
|
||||||
|
return match response_wait {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok(result)) => result,
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(_)) => {
|
||||||
|
std::result::Result::Err(ws_session_closed_error(self.id, "WebSocket session ended before the JSON-RPC response was delivered"))
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
std::result::Result::Err(ws_timeout_error(self.id, "WebSocket JSON-RPC request exceeded the configured command timeout"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for WsSession {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
return formatter.debug_struct("WsSession").field("id", &self.id).field("snapshot", &self.snapshot()).finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WsSessionCommand {
|
||||||
|
ExecuteJsonRpc {
|
||||||
|
method: &'static str,
|
||||||
|
params: std::vec::Vec<serde_json::Value>,
|
||||||
|
response_tx: tokio::sync::oneshot::Sender<ksp_core_lib::Result<serde_json::Value>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingWsRequest {
|
||||||
|
method: &'static str,
|
||||||
|
deadline: tokio::time::Instant,
|
||||||
|
response_tx: tokio::sync::oneshot::Sender<ksp_core_lib::Result<serde_json::Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_ws_session_actor(
|
||||||
|
id: crate::WsSessionId,
|
||||||
|
endpoint: crate::WsEndpointSettings,
|
||||||
|
mut command_rx: tokio::sync::mpsc::Receiver<WsSessionCommand>,
|
||||||
|
snapshot_tx: tokio::sync::watch::Sender<crate::WsSessionSnapshot>,
|
||||||
|
startup_tx: tokio::sync::oneshot::Sender<ksp_core_lib::Result<()>>,
|
||||||
|
) {
|
||||||
|
let websocket_config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
|
||||||
|
.write_buffer_size(0)
|
||||||
|
.max_write_buffer_size(endpoint.session().max_write_buffer_size_bytes())
|
||||||
|
.max_message_size(std::option::Option::Some(endpoint.session().max_message_size_bytes()))
|
||||||
|
.max_frame_size(std::option::Option::Some(endpoint.session().max_frame_size_bytes()));
|
||||||
|
ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
endpoint_name = endpoint.name(),
|
||||||
|
provider = endpoint.provider().as_str(),
|
||||||
|
cluster = endpoint.cluster().as_str(),
|
||||||
|
"opening physical WebSocket connection"
|
||||||
|
);
|
||||||
|
let connect_wait = tokio::time::timeout(
|
||||||
|
endpoint.session().command_timeout(),
|
||||||
|
tokio_tungstenite::connect_async_with_config(endpoint.url().as_str(), std::option::Option::Some(websocket_config), false),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (mut websocket, handshake_status) = match connect_wait {
|
||||||
|
std::result::Result::Ok(std::result::Result::Ok((websocket, response))) => (websocket, response.status().as_u16()),
|
||||||
|
std::result::Result::Ok(std::result::Result::Err(_)) => {
|
||||||
|
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Failed, 0);
|
||||||
|
let error = ws_connection_error(id, &endpoint, "WebSocket connection or handshake failed");
|
||||||
|
let _ = startup_tx.send(std::result::Result::Err(error));
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
endpoint_name = endpoint.name(),
|
||||||
|
provider = endpoint.provider().as_str(),
|
||||||
|
cluster = endpoint.cluster().as_str(),
|
||||||
|
"physical WebSocket connection failed"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Failed, 0);
|
||||||
|
let error = ws_timeout_error(id, "WebSocket connection handshake timed out");
|
||||||
|
let _ = startup_tx.send(std::result::Result::Err(error));
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
endpoint_name = endpoint.name(),
|
||||||
|
"physical WebSocket connection handshake timed out"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Active, 0);
|
||||||
|
let _ = startup_tx.send(std::result::Result::Ok(()));
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
endpoint_name = endpoint.name(),
|
||||||
|
handshake_status,
|
||||||
|
"physical WebSocket session is active"
|
||||||
|
);
|
||||||
|
let mut next_request_id = 1_u64;
|
||||||
|
let mut pending = std::collections::BTreeMap::<u64, PendingWsRequest>::new();
|
||||||
|
loop {
|
||||||
|
let timeout_deadline = next_pending_deadline(&pending);
|
||||||
|
tokio::select! {
|
||||||
|
maybe_command = command_rx.recv() => {
|
||||||
|
let command = match maybe_command {
|
||||||
|
std::option::Option::Some(command) => command,
|
||||||
|
std::option::Option::None => {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, session_id = id.get(), "all WebSocket session handles dropped; closing actor");
|
||||||
|
let _ = websocket.send(tokio_tungstenite::tungstenite::Message::Close(std::option::Option::None)).await;
|
||||||
|
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Closed, pending.len());
|
||||||
|
fail_all_pending(&mut pending, id, crate::ERROR_CODE_WS_SESSION_CLOSED, "WebSocket session closed before pending response delivery");
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let command_outcome = handle_session_command(id, &endpoint, &mut websocket, &mut pending, &mut next_request_id, command).await;
|
||||||
|
if !command_outcome {
|
||||||
|
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Failed, pending.len());
|
||||||
|
fail_all_pending(&mut pending, id, crate::ERROR_CODE_WS_CONNECTION_FAILED, "WebSocket connection failed while writing a request");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Active, pending.len());
|
||||||
|
},
|
||||||
|
maybe_message = websocket.next() => {
|
||||||
|
let keep_running = handle_socket_message(id, &endpoint, maybe_message, &mut websocket, &mut pending).await;
|
||||||
|
if !keep_running {
|
||||||
|
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Failed, pending.len());
|
||||||
|
fail_all_pending(&mut pending, id, crate::ERROR_CODE_WS_CONNECTION_FAILED, "WebSocket connection ended before pending response delivery");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Active, pending.len());
|
||||||
|
},
|
||||||
|
() = tokio::time::sleep_until(timeout_deadline) => {
|
||||||
|
expire_pending_requests(id, &mut pending);
|
||||||
|
publish_snapshot(&snapshot_tx, id, &endpoint, crate::WsSessionState::Active, pending.len());
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_session_command<S>(
|
||||||
|
id: crate::WsSessionId,
|
||||||
|
endpoint: &crate::WsEndpointSettings,
|
||||||
|
websocket: &mut tokio_tungstenite::WebSocketStream<S>,
|
||||||
|
pending: &mut std::collections::BTreeMap<u64, PendingWsRequest>,
|
||||||
|
next_request_id: &mut u64,
|
||||||
|
command: WsSessionCommand,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + std::marker::Unpin,
|
||||||
|
{
|
||||||
|
return match command {
|
||||||
|
WsSessionCommand::ExecuteJsonRpc { method, params, response_tx } => {
|
||||||
|
if pending.len() >= endpoint.session().max_pending_requests() {
|
||||||
|
let error = ksp_core_lib::Error::new(crate::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW, "WebSocket pending JSON-RPC request capacity is exhausted")
|
||||||
|
.with_context("session_id", id.get().to_string())
|
||||||
|
.with_context("method", method);
|
||||||
|
let _ = response_tx.send(std::result::Result::Err(error));
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
pending_request_count = pending.len(),
|
||||||
|
max_pending_requests = endpoint.session().max_pending_requests(),
|
||||||
|
"rejected WebSocket JSON-RPC request because pending capacity is exhausted"
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let request_id = *next_request_id;
|
||||||
|
let incremented = request_id.checked_add(1);
|
||||||
|
*next_request_id = match incremented {
|
||||||
|
std::option::Option::Some(value) => value,
|
||||||
|
std::option::Option::None => {
|
||||||
|
let error = ksp_core_lib::Error::new(crate::ERROR_CODE_WS_PROTOCOL_ERROR, "WebSocket JSON-RPC request identifier space is exhausted")
|
||||||
|
.with_context("session_id", id.get().to_string());
|
||||||
|
let _ = response_tx.send(std::result::Result::Err(error));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let request_result = crate::JsonRpcRequest::new(request_id, method, params);
|
||||||
|
let request = match request_result {
|
||||||
|
std::result::Result::Ok(request) => request,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let _ = response_tx.send(std::result::Result::Err(error));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let payload_result = request.to_json_string();
|
||||||
|
let payload = match payload_result {
|
||||||
|
std::result::Result::Ok(payload) => payload,
|
||||||
|
std::result::Result::Err(error) => {
|
||||||
|
let _ = response_tx.send(std::result::Result::Err(error));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let deadline = tokio::time::Instant::now() + endpoint.session().command_timeout();
|
||||||
|
pending.insert(request_id, PendingWsRequest { method, deadline, response_tx });
|
||||||
|
ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
request_id,
|
||||||
|
method,
|
||||||
|
pending_request_count = pending.len(),
|
||||||
|
"sending WebSocket JSON-RPC request"
|
||||||
|
);
|
||||||
|
let send_result = websocket.send(tokio_tungstenite::tungstenite::Message::Text(payload.into())).await;
|
||||||
|
if send_result.is_err() {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
request_id,
|
||||||
|
method,
|
||||||
|
"WebSocket JSON-RPC request write failed"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_socket_message<S>(
|
||||||
|
id: crate::WsSessionId,
|
||||||
|
endpoint: &crate::WsEndpointSettings,
|
||||||
|
maybe_message: std::option::Option<std::result::Result<tokio_tungstenite::tungstenite::Message, tokio_tungstenite::tungstenite::Error>>,
|
||||||
|
websocket: &mut tokio_tungstenite::WebSocketStream<S>,
|
||||||
|
pending: &mut std::collections::BTreeMap<u64, PendingWsRequest>,
|
||||||
|
) -> bool
|
||||||
|
where
|
||||||
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + std::marker::Unpin,
|
||||||
|
{
|
||||||
|
let message = match maybe_message {
|
||||||
|
std::option::Option::Some(std::result::Result::Ok(message)) => message,
|
||||||
|
std::option::Option::Some(std::result::Result::Err(_)) => {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
endpoint_name = endpoint.name(),
|
||||||
|
"physical WebSocket read failed"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
std::option::Option::None => {
|
||||||
|
ksp_logging_lib::warn!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
endpoint_name = endpoint.name(),
|
||||||
|
"physical WebSocket stream ended"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match message {
|
||||||
|
tokio_tungstenite::tungstenite::Message::Text(text) => handle_text_message(id, text.as_str(), pending),
|
||||||
|
tokio_tungstenite::tungstenite::Message::Binary(_) => {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, session_id = id.get(), "received unexpected binary WebSocket message");
|
||||||
|
false
|
||||||
|
},
|
||||||
|
tokio_tungstenite::tungstenite::Message::Ping(payload) => {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, session_id = id.get(), "received WebSocket ping control frame");
|
||||||
|
return websocket.send(tokio_tungstenite::tungstenite::Message::Pong(payload)).await.is_ok();
|
||||||
|
},
|
||||||
|
tokio_tungstenite::tungstenite::Message::Pong(_) => {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, session_id = id.get(), "received WebSocket pong control frame");
|
||||||
|
true
|
||||||
|
},
|
||||||
|
tokio_tungstenite::tungstenite::Message::Close(_) => {
|
||||||
|
ksp_logging_lib::debug!(target: crate::TRACING_TARGET, session_id = id.get(), "remote peer closed physical WebSocket session");
|
||||||
|
false
|
||||||
|
},
|
||||||
|
tokio_tungstenite::tungstenite::Message::Frame(_) => {
|
||||||
|
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, session_id = id.get(), "ignored internal WebSocket frame event");
|
||||||
|
true
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_text_message(id: crate::WsSessionId, text: &str, pending: &mut std::collections::BTreeMap<u64, PendingWsRequest>) -> bool {
|
||||||
|
let decoded = serde_json::from_str::<serde_json::Value>(text);
|
||||||
|
let value = match decoded {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, session_id = id.get(), "received malformed JSON WebSocket message");
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let object = match value.as_object() {
|
||||||
|
std::option::Option::Some(object) => object,
|
||||||
|
std::option::Option::None => {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, session_id = id.get(), "received non-object JSON WebSocket message");
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if !object.contains_key("id") {
|
||||||
|
if object.contains_key("method") && object.contains_key("params") {
|
||||||
|
ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
"received WebSocket notification before subscription registry activation; safely ignored"
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, session_id = id.get(), "received structurally invalid WebSocket JSON-RPC message");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let response_id = match object.get("id").and_then(serde_json::Value::as_u64) {
|
||||||
|
std::option::Option::Some(response_id) => response_id,
|
||||||
|
std::option::Option::None => {
|
||||||
|
ksp_logging_lib::warn!(target: crate::TRACING_TARGET, session_id = id.get(), "received WebSocket JSON-RPC response with invalid id");
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let pending_request = match pending.remove(&response_id) {
|
||||||
|
std::option::Option::Some(pending_request) => pending_request,
|
||||||
|
std::option::Option::None => {
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
response_id,
|
||||||
|
"ignored unknown or stale WebSocket JSON-RPC response id"
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let parsed = crate::parse_json_rpc_response_value(value, response_id);
|
||||||
|
let (result, keep_running) = match parsed {
|
||||||
|
std::result::Result::Ok(response) => {
|
||||||
|
let result = match response.into_result() {
|
||||||
|
std::result::Result::Ok(value) => std::result::Result::Ok(value),
|
||||||
|
std::result::Result::Err(error) => std::result::Result::Err(error.with_context("method", pending_request.method)),
|
||||||
|
};
|
||||||
|
(result, true)
|
||||||
|
},
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
let error = ksp_core_lib::Error::new(crate::ERROR_CODE_WS_PROTOCOL_ERROR, "WebSocket JSON-RPC response violates protocol invariants")
|
||||||
|
.with_context("session_id", id.get().to_string())
|
||||||
|
.with_context("request_id", response_id.to_string())
|
||||||
|
.with_context("method", pending_request.method);
|
||||||
|
(std::result::Result::Err(error), false)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let _ = pending_request.response_tx.send(result);
|
||||||
|
ksp_logging_lib::trace!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
response_id,
|
||||||
|
method = pending_request.method,
|
||||||
|
pending_request_count = pending.len(),
|
||||||
|
"dispatched WebSocket JSON-RPC response to pending request"
|
||||||
|
);
|
||||||
|
return keep_running;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_pending_deadline(pending: &std::collections::BTreeMap<u64, PendingWsRequest>) -> tokio::time::Instant {
|
||||||
|
let mut earliest = std::option::Option::None::<tokio::time::Instant>;
|
||||||
|
for request in pending.values() {
|
||||||
|
earliest = match earliest {
|
||||||
|
std::option::Option::Some(current) if current <= request.deadline => std::option::Option::Some(current),
|
||||||
|
_ => std::option::Option::Some(request.deadline),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return match earliest {
|
||||||
|
std::option::Option::Some(deadline) => deadline,
|
||||||
|
std::option::Option::None => tokio::time::Instant::now() + std::time::Duration::from_secs(86_400),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expire_pending_requests(id: crate::WsSessionId, pending: &mut std::collections::BTreeMap<u64, PendingWsRequest>) {
|
||||||
|
let now = tokio::time::Instant::now();
|
||||||
|
let expired_ids = pending
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(request_id, request)| if request.deadline <= now { std::option::Option::Some(*request_id) } else { std::option::Option::None })
|
||||||
|
.collect::<std::vec::Vec<_>>();
|
||||||
|
for request_id in expired_ids {
|
||||||
|
if let std::option::Option::Some(request) = pending.remove(&request_id) {
|
||||||
|
let error = ws_timeout_error(id, "WebSocket JSON-RPC request timed out while awaiting the remote response").with_context("method", request.method);
|
||||||
|
let _ = request.response_tx.send(std::result::Result::Err(error));
|
||||||
|
ksp_logging_lib::debug!(
|
||||||
|
target: crate::TRACING_TARGET,
|
||||||
|
session_id = id.get(),
|
||||||
|
request_id,
|
||||||
|
method = request.method,
|
||||||
|
"expired pending WebSocket JSON-RPC request"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail_all_pending(
|
||||||
|
pending: &mut std::collections::BTreeMap<u64, PendingWsRequest>,
|
||||||
|
id: crate::WsSessionId,
|
||||||
|
code: ksp_core_lib::ErrorCode,
|
||||||
|
message: &'static str,
|
||||||
|
) {
|
||||||
|
let requests = std::mem::take(pending);
|
||||||
|
for (request_id, request) in requests {
|
||||||
|
let error = ksp_core_lib::Error::new(code, message)
|
||||||
|
.with_context("session_id", id.get().to_string())
|
||||||
|
.with_context("request_id", request_id.to_string())
|
||||||
|
.with_context("method", request.method);
|
||||||
|
let _ = request.response_tx.send(std::result::Result::Err(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_snapshot(
|
||||||
|
snapshot_tx: &tokio::sync::watch::Sender<crate::WsSessionSnapshot>,
|
||||||
|
id: crate::WsSessionId,
|
||||||
|
endpoint: &crate::WsEndpointSettings,
|
||||||
|
state: crate::WsSessionState,
|
||||||
|
pending_request_count: usize,
|
||||||
|
) {
|
||||||
|
let snapshot = crate::WsSessionSnapshot::new(
|
||||||
|
id,
|
||||||
|
endpoint.name(),
|
||||||
|
endpoint.provider().clone(),
|
||||||
|
endpoint.cluster().clone(),
|
||||||
|
endpoint.protocol(),
|
||||||
|
state,
|
||||||
|
pending_request_count,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
std::vec::Vec::new(),
|
||||||
|
);
|
||||||
|
snapshot_tx.send_replace(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_session_id() -> ksp_core_lib::Result<crate::WsSessionId> {
|
||||||
|
let update_result =
|
||||||
|
NEXT_WS_SESSION_ID.fetch_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |current| current.checked_add(1));
|
||||||
|
let value = match update_result {
|
||||||
|
std::result::Result::Ok(value) => value,
|
||||||
|
std::result::Result::Err(_) => {
|
||||||
|
return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_WS_PROTOCOL_ERROR, "WebSocket session identity space is exhausted"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return match std::num::NonZeroU64::new(value) {
|
||||||
|
std::option::Option::Some(value) => std::result::Result::Ok(crate::WsSessionId::new(value)),
|
||||||
|
std::option::Option::None => {
|
||||||
|
std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_WS_PROTOCOL_ERROR, "WebSocket session identity generator produced zero"))
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ws_connection_error(id: crate::WsSessionId, endpoint: &crate::WsEndpointSettings, message: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_WS_CONNECTION_FAILED, message)
|
||||||
|
.with_context("session_id", id.get().to_string())
|
||||||
|
.with_context("endpoint_name", endpoint.name())
|
||||||
|
.with_context("provider", endpoint.provider().as_str())
|
||||||
|
.with_context("cluster", endpoint.cluster().as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ws_session_closed_error(id: crate::WsSessionId, message: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_WS_SESSION_CLOSED, message).with_context("session_id", id.get().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ws_timeout_error(id: crate::WsSessionId, message: &'static str) -> ksp_core_lib::Error {
|
||||||
|
return ksp_core_lib::Error::new(crate::ERROR_CODE_TIMEOUT, message).with_context("session_id", id.get().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "../unit_tests/ws_session.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
// file: crates/ksp-onchain-transport-lib/tests/public_api.rs
|
||||||
// version: 25
|
// version: 26
|
||||||
|
|
||||||
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
//! Integration tests for the public `ksp-onchain-transport-lib` consumer contract.
|
||||||
|
|
||||||
@@ -549,3 +549,13 @@ fn public_v0_2_7_pre_002_websocket_settings_and_lifecycle_contracts_are_availabl
|
|||||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::Slot.as_str(), "slot");
|
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionKind::Slot.as_str(), "slot");
|
||||||
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionState::Requested, ksp_onchain_transport_lib::WsSubscriptionState::Requested);
|
assert_eq!(ksp_onchain_transport_lib::WsSubscriptionState::Requested, ksp_onchain_transport_lib::WsSubscriptionState::Requested);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_v0_2_7_pre_004_physical_websocket_session_contract_is_available_from_crate_root() {
|
||||||
|
let type_name = std::any::type_name::<ksp_onchain_transport_lib::WsSession>();
|
||||||
|
assert!(type_name.ends_with("WsSession"));
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_BACKPRESSURE_OVERFLOW.code(), "ws_backpressure_overflow");
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_CONNECTION_FAILED.code(), "ws_connection_failed");
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_PROTOCOL_ERROR.code(), "ws_protocol_error");
|
||||||
|
assert_eq!(ksp_onchain_transport_lib::ERROR_CODE_WS_SESSION_CLOSED.code(), "ws_session_closed");
|
||||||
|
}
|
||||||
|
|||||||
151
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
Normal file
151
crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// file: crates/ksp-onchain-transport-lib/unit_tests/ws_session.rs
|
||||||
|
// version: 1
|
||||||
|
|
||||||
|
use futures_util::SinkExt; // rust-rules: trait-import
|
||||||
|
use futures_util::StreamExt; // rust-rules: trait-import
|
||||||
|
|
||||||
|
fn local_endpoint(url: &str) -> crate::WsEndpointSettings {
|
||||||
|
return crate::WsEndpointSettings::new(
|
||||||
|
"local_ws",
|
||||||
|
true,
|
||||||
|
crate::WsProviderName::new("local-fixture"),
|
||||||
|
crate::WsClusterName::new("local"),
|
||||||
|
crate::WsProtocolKind::SolanaStandard,
|
||||||
|
crate::WsEndpointUrl::parse(url).expect("local test WebSocket URL must parse"),
|
||||||
|
crate::WsSessionSettings::default(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bind_local_listener() -> (tokio::net::TcpListener, std::string::String) {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("local listener must bind");
|
||||||
|
let address = listener.local_addr().expect("local listener must expose address");
|
||||||
|
return (listener, format!("ws://{address}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_request(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>) -> serde_json::Value {
|
||||||
|
let message = websocket.next().await.expect("request message must exist").expect("request message must decode");
|
||||||
|
let text = message.to_text().expect("request must be text");
|
||||||
|
return serde_json::from_str(text).expect("request must contain JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_result(websocket: &mut tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>, request: &serde_json::Value, result: serde_json::Value) {
|
||||||
|
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||||
|
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"result":result});
|
||||||
|
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local response must send");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn websocket_session_connects_and_round_trips_internal_json_rpc() {
|
||||||
|
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 request = read_request(&mut websocket).await;
|
||||||
|
assert_eq!(request.get("method").and_then(serde_json::Value::as_str), std::option::Option::Some("getVersion"));
|
||||||
|
send_result(&mut websocket, &request, serde_json::json!({"solana-core":"fixture"})).await;
|
||||||
|
});
|
||||||
|
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||||
|
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||||
|
assert_eq!(session.snapshot().pending_request_count(), 0);
|
||||||
|
let result = session.execute_json_rpc("getVersion", std::vec::Vec::new()).await.expect("fixture JSON-RPC call must succeed");
|
||||||
|
assert_eq!(result.get("solana-core").and_then(serde_json::Value::as_str), std::option::Option::Some("fixture"));
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert_eq!(session.snapshot().pending_request_count(), 0);
|
||||||
|
server.await.expect("local server task must complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn websocket_sessions_are_physical_and_distinct_even_for_the_same_url() {
|
||||||
|
let (listener, url) = bind_local_listener().await;
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let first = listener.accept().await.expect("first local client must connect");
|
||||||
|
let first_ws = tokio_tungstenite::accept_async(first.0).await.expect("first handshake must succeed");
|
||||||
|
let second = listener.accept().await.expect("second local client must connect");
|
||||||
|
let second_ws = tokio_tungstenite::accept_async(second.0).await.expect("second handshake must succeed");
|
||||||
|
return (first_ws, second_ws);
|
||||||
|
});
|
||||||
|
let first = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("first client must connect");
|
||||||
|
let second = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("second client must connect");
|
||||||
|
assert_ne!(first.id(), second.id());
|
||||||
|
assert_eq!(first.snapshot().endpoint_name(), second.snapshot().endpoint_name());
|
||||||
|
let _ = server.await.expect("local server task must complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn websocket_pending_map_dispatches_out_of_order_responses_by_request_id() {
|
||||||
|
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;
|
||||||
|
let second = read_request(&mut websocket).await;
|
||||||
|
send_result(&mut websocket, &second, serde_json::json!("second")).await;
|
||||||
|
send_result(&mut websocket, &first, serde_json::json!("first")).await;
|
||||||
|
});
|
||||||
|
let session = crate::WsSession::connect(local_endpoint(url.as_str())).await.expect("client handshake must succeed");
|
||||||
|
let first_session = session.clone();
|
||||||
|
let second_session = session.clone();
|
||||||
|
let (first, second) = tokio::join!(
|
||||||
|
first_session.execute_json_rpc("firstMethod", std::vec::Vec::new()),
|
||||||
|
second_session.execute_json_rpc("secondMethod", std::vec::Vec::new())
|
||||||
|
);
|
||||||
|
assert_eq!(first.expect("first response must dispatch"), serde_json::json!("first"));
|
||||||
|
assert_eq!(second.expect("second response must dispatch"), serde_json::json!("second"));
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
assert_eq!(session.snapshot().pending_request_count(), 0);
|
||||||
|
server.await.expect("local server task must complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "current_thread")]
|
||||||
|
async fn websocket_rpc_application_error_does_not_fail_the_physical_session() {
|
||||||
|
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 request = read_request(&mut websocket).await;
|
||||||
|
let id = request.get("id").and_then(serde_json::Value::as_u64).expect("request id must be numeric");
|
||||||
|
let response = serde_json::json!({"jsonrpc":"2.0","id":id,"error":{"code":-32602,"message":"fixture invalid params"}});
|
||||||
|
websocket.send(tokio_tungstenite::tungstenite::Message::Text(response.to_string().into())).await.expect("local error response must send");
|
||||||
|
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 error = session.execute_json_rpc("badMethod", std::vec::Vec::new()).await.expect_err("RPC application error must surface");
|
||||||
|
assert_eq!(error.code(), crate::ERROR_CODE_RPC_APPLICATION_ERROR);
|
||||||
|
assert_eq!(session.state(), crate::WsSessionState::Active);
|
||||||
|
let result = session.execute_json_rpc("goodMethod", std::vec::Vec::new()).await.expect("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_connection_errors_do_not_echo_url_credentials() {
|
||||||
|
let defaults = crate::WsSessionSettings::default();
|
||||||
|
let short_session = crate::WsSessionSettings::new(
|
||||||
|
std::time::Duration::from_millis(150),
|
||||||
|
defaults.close_timeout(),
|
||||||
|
defaults.reconnect().clone(),
|
||||||
|
defaults.resubscribe(),
|
||||||
|
defaults.command_queue_capacity(),
|
||||||
|
defaults.notification_queue_capacity(),
|
||||||
|
defaults.max_active_subscriptions(),
|
||||||
|
defaults.max_pending_requests(),
|
||||||
|
defaults.max_message_size_bytes(),
|
||||||
|
defaults.max_frame_size_bytes(),
|
||||||
|
defaults.max_write_buffer_size_bytes(),
|
||||||
|
);
|
||||||
|
let endpoint = crate::WsEndpointSettings::new(
|
||||||
|
"redaction_fixture",
|
||||||
|
true,
|
||||||
|
crate::WsProviderName::new("fixture-provider"),
|
||||||
|
crate::WsClusterName::new("local"),
|
||||||
|
crate::WsProtocolKind::SolanaStandard,
|
||||||
|
crate::WsEndpointUrl::parse("ws://user:password@127.0.0.1:1/private?api-key=SECRET-CANARY").expect("test URL must parse"),
|
||||||
|
short_session,
|
||||||
|
);
|
||||||
|
let error = crate::WsSession::connect(endpoint).await.expect_err("unreachable local endpoint must fail");
|
||||||
|
let rendered = format!("{error:?}");
|
||||||
|
assert!(!rendered.contains("SECRET-CANARY"));
|
||||||
|
assert!(!rendered.contains("password"));
|
||||||
|
assert!(!rendered.contains("private?"));
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ La documentation Solana reste l'autorité pour la **surface publique annoncée**
|
|||||||
Le plan et la compliance suivent désormais explicitement les SIMDs pertinents :
|
Le plan et la compliance suivent désormais explicitement les SIMDs pertinents :
|
||||||
|
|
||||||
| SIMD | Statut | Décision KSP principale |
|
| SIMD | Statut | Décision KSP principale |
|
||||||
|------------------------------------------------|-----------|----------------------------------------------------------------------------------------------------------------------|
|
|-----------------------------------------------|-----------|-------------------------------------------------------------------------------------------------------------------|
|
||||||
| `0118` Partitioned Epoch Rewards Distribution | Activated | réutiliser les DTOs bloc/rewards lossless déjà acquis par HTTP |
|
| `0118` Partitioned Epoch Rewards Distribution | Activated | réutiliser les DTOs bloc/rewards lossless déjà acquis par HTTP |
|
||||||
| `0291` Commission Rate in Basis Points | Review | ne pas dériver localement une représentation de commission depuis une autre |
|
| `0291` Commission Rate in Basis Points | Review | ne pas dériver localement une représentation de commission depuis une autre |
|
||||||
| `0296` Larger Transaction Size | Review | proposition jusqu'à 4096 octets ; aucune limite WS dérivée en dur de l'ancienne taille transaction de 1232 octets |
|
| `0296` Larger Transaction Size | Review | proposition jusqu'à 4096 octets ; aucune limite WS dérivée en dur de l'ancienne taille transaction de 1232 octets |
|
||||||
|
|||||||
240
deltas/0.2.7/pre.004.md
Normal file
240
deltas/0.2.7/pre.004.md
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
<!-- file: deltas/0.2.7/pre.004.md -->
|
||||||
|
<!-- version: 1 -->
|
||||||
|
|
||||||
|
# Delta `0.2.7-pre.004` — runtime WebSocket physique + actor JSON-RPC
|
||||||
|
|
||||||
|
## 1. Base requise
|
||||||
|
|
||||||
|
```text
|
||||||
|
0.2.7-pre.003 appliquée
|
||||||
|
workspace.package.version = 0.2.7-pre.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Le checkpoint opérateur reçu avant cette tranche est vert : `cargo fmt --all`, audit Python, `cargo check --workspace`, `cargo clippy --workspace --all-targets`, tests Transport, tests Config et `cargo test --workspace`.
|
||||||
|
|
||||||
|
## 2. Signal technique
|
||||||
|
|
||||||
|
Cette prerelease non-fix modifie dépendances, runtime Rust et tests. Conformément au workflow KSP :
|
||||||
|
|
||||||
|
```text
|
||||||
|
livraison = 0.2.7-pre.004
|
||||||
|
workspace.package.version = 0.2.7-pre.4
|
||||||
|
commit = v0.2.7-pre.004
|
||||||
|
```
|
||||||
|
|
||||||
|
Aucun tag prerelease.
|
||||||
|
|
||||||
|
## 3. Dépendances WebSocket matérialisées
|
||||||
|
|
||||||
|
Le réaudit du 22 août 2026 confirme les versions retenues depuis `pre.001` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
tokio-tungstenite 0.30.0
|
||||||
|
futures-util 0.3.34
|
||||||
|
```
|
||||||
|
|
||||||
|
Le root déclare sans features consumer :
|
||||||
|
|
||||||
|
```toml
|
||||||
|
tokio-tungstenite = { version = "^0.30", default-features = false }
|
||||||
|
futures-util = { version = "^0.3", default-features = false }
|
||||||
|
```
|
||||||
|
|
||||||
|
Transport active seulement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
tokio-tungstenite : connect + rustls-tls-webpki-roots
|
||||||
|
futures-util : sink + std
|
||||||
|
tokio : macros + rt + sync + time
|
||||||
|
```
|
||||||
|
|
||||||
|
Le fixture serveur local ajoute `tokio/net` côté dev.
|
||||||
|
|
||||||
|
Aucune dépendance Config, Store, Program, Wallet ou `tracing` direct n'est introduite.
|
||||||
|
|
||||||
|
## 4. `WsSession` physique
|
||||||
|
|
||||||
|
Nouvelle surface publique :
|
||||||
|
|
||||||
|
```text
|
||||||
|
WsSession::connect(WsEndpointSettings)
|
||||||
|
WsSession::id()
|
||||||
|
WsSession::state()
|
||||||
|
WsSession::snapshot()
|
||||||
|
```
|
||||||
|
|
||||||
|
Un appel de `connect` crée exactement une connexion physique. Deux appels avec le même endpoint créent deux sockets indépendants ; aucun singleton, pool ou scheduler automatique n'est ajouté.
|
||||||
|
|
||||||
|
Le caller ne reçoit jamais le socket brut.
|
||||||
|
|
||||||
|
## 5. Actor propriétaire du socket
|
||||||
|
|
||||||
|
Une tâche actor unique possède :
|
||||||
|
|
||||||
|
```text
|
||||||
|
WebSocketStream
|
||||||
|
compteur JSON-RPC request id
|
||||||
|
map pending requests
|
||||||
|
bounded command receiver
|
||||||
|
publication WsSessionSnapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Le handle communique avec l'actor par `tokio::sync::mpsc` borné selon `command_queue_capacity`.
|
||||||
|
|
||||||
|
Les snapshots sont publiés via `tokio::sync::watch` et conservent seulement les metadata sûres prévues en `pre.002`.
|
||||||
|
|
||||||
|
## 6. Handshake et `WebSocketConfig`
|
||||||
|
|
||||||
|
`WsSession::connect` attend le handshake sous `command_timeout` et configure explicitement :
|
||||||
|
|
||||||
|
```text
|
||||||
|
write_buffer_size = 0
|
||||||
|
max_write_buffer_size = WsSessionSettings.max_write_buffer_size_bytes
|
||||||
|
max_message_size = WsSessionSettings.max_message_size_bytes
|
||||||
|
max_frame_size = WsSessionSettings.max_frame_size_bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `write_buffer_size = 0` évite de rendre la validité de la configuration KSP dépendante du buffer par défaut interne de Tungstenite et garantit que le plafond configuré reste strictement supérieur au target buffer.
|
||||||
|
|
||||||
|
Les tests oversized et les recalibrages éventuels restent le gate `pre.005`.
|
||||||
|
|
||||||
|
## 7. Pending JSON-RPC
|
||||||
|
|
||||||
|
La primitive interne actor :
|
||||||
|
|
||||||
|
```text
|
||||||
|
execute_json_rpc(method, params)
|
||||||
|
```
|
||||||
|
|
||||||
|
reste **`pub(crate)`**. Elle n'est volontairement pas exposée comme API raw provider-extension publique.
|
||||||
|
|
||||||
|
Comportement :
|
||||||
|
|
||||||
|
- ID numérique KSP monotone par session ;
|
||||||
|
- sérialisation via `JsonRpcRequest` existant ;
|
||||||
|
- map `BTreeMap` bornée par `max_pending_requests` ;
|
||||||
|
- deadline par request issue de `command_timeout` ;
|
||||||
|
- dispatch des réponses par `id`, y compris si elles arrivent hors ordre ;
|
||||||
|
- erreurs JSON-RPC applicatives renvoyées au caller concerné sans teardown de la connexion ;
|
||||||
|
- ID réponse inconnu/stale ignoré avec diagnostic sûr ;
|
||||||
|
- JSON structurellement invalide classé erreur protocole session.
|
||||||
|
|
||||||
|
Cette primitive sera consommée par le moteur de subscriptions à partir de `pre.006`.
|
||||||
|
|
||||||
|
## 8. Lifecycle limité à la tranche
|
||||||
|
|
||||||
|
`pre.004` matérialise :
|
||||||
|
|
||||||
|
```text
|
||||||
|
Connecting -> Active
|
||||||
|
connection/read/write failure -> Failed
|
||||||
|
last handle dropped -> cleanup best-effort -> Closed
|
||||||
|
```
|
||||||
|
|
||||||
|
Le reconnect/resubscribe reste `pre.007`.
|
||||||
|
|
||||||
|
Le shutdown async public, les budgets de Close et les fixtures peer hostile restent `pre.005`.
|
||||||
|
|
||||||
|
Ping reçu est répondu par Pong afin de conserver l'interopérabilité du socket. Aucun heartbeat applicatif périodique n'est ajouté.
|
||||||
|
|
||||||
|
## 9. Erreurs
|
||||||
|
|
||||||
|
Nouveaux codes publics :
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws_backpressure_overflow
|
||||||
|
ws_connection_failed
|
||||||
|
ws_protocol_error
|
||||||
|
ws_session_closed
|
||||||
|
```
|
||||||
|
|
||||||
|
Les erreurs de connexion WebSocket ne conservent volontairement pas la source Tungstenite brute : celle-ci pourrait contenir une request/URI ou d'autres détails provider. Les erreurs KSP exposent uniquement `session_id`, endpoint logique, provider et cluster.
|
||||||
|
|
||||||
|
## 10. Logging / tracing
|
||||||
|
|
||||||
|
Toutes les émissions passent exclusivement par `ksp-logging-lib` et réutilisent :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/src/constants.rs
|
||||||
|
TRACING_TARGET = "ksp-onchain-transport-lib"
|
||||||
|
```
|
||||||
|
|
||||||
|
Répartition principale :
|
||||||
|
|
||||||
|
```text
|
||||||
|
trace -> ouverture socket, send/dispatch JSON-RPC, Ping/Pong, notification prématurée ignorée
|
||||||
|
debug -> actor start, handshake actif, réponse stale, timeout pending, remote close
|
||||||
|
warn -> handshake/read/write failure, malformed wire, capacité pending épuisée
|
||||||
|
```
|
||||||
|
|
||||||
|
Ne sont jamais loggés : URL complète, credentials, query token, payload JSON-RPC complet ou notification brute.
|
||||||
|
|
||||||
|
## 11. Serveur local déterministe
|
||||||
|
|
||||||
|
Nouveaux tests runtime sans Internet :
|
||||||
|
|
||||||
|
```text
|
||||||
|
handshake local + round-trip JSON-RPC
|
||||||
|
deux sessions physiques distinctes sur la même URL
|
||||||
|
deux requests concurrentes + réponses inversées
|
||||||
|
application error sans teardown de session
|
||||||
|
connection error sans fuite URL/credential
|
||||||
|
```
|
||||||
|
|
||||||
|
Le serveur utilise `tokio::net::TcpListener` + `tokio_tungstenite::accept_async`.
|
||||||
|
|
||||||
|
## 12. Canaris workspace/public API
|
||||||
|
|
||||||
|
Le canari workspace dependencies est synchronisé avec les nouvelles dépendances/features et continue de vérifier le firewall Transport.
|
||||||
|
|
||||||
|
Le canari public API vérifie la disponibilité de `WsSession` et les quatre nouveaux codes d'erreur.
|
||||||
|
|
||||||
|
## 13. Documentation synchronisée
|
||||||
|
|
||||||
|
Mis à jour :
|
||||||
|
|
||||||
|
```text
|
||||||
|
crates/ksp-onchain-transport-lib/README.md
|
||||||
|
crates/ksp-onchain-transport-lib/USAGE.md
|
||||||
|
docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md
|
||||||
|
docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`ROADMAP.md` et `CHANGELOG.md` restent inchangés conformément à la politique de série prerelease.
|
||||||
|
|
||||||
|
## 14. Validation de préparation
|
||||||
|
|
||||||
|
Exécuté 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
|
||||||
|
|
||||||
|
inspection absence tracing direct OK
|
||||||
|
inspection TRACING_TARGET OK
|
||||||
|
inspection dépendances workspace/member OK
|
||||||
|
inspection Markdown tables OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Cargo n'est pas disponible dans le sandbox de génération ; aucun résultat Cargo local n'est revendiqué.
|
||||||
|
|
||||||
|
## 15. 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.004
|
||||||
|
```
|
||||||
|
|
||||||
|
La tranche suivante est `0.2.7-pre.005` : adversarial limits frame/message/request, control frames, cancellation, close/shutdown explicite et peer hostile.
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- file: docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md -->
|
<!-- file: docs/plans/014-V0_2_7_ONCHAIN_WEBSOCKET_PLAN.md -->
|
||||||
<!-- version: 5 -->
|
<!-- version: 6 -->
|
||||||
|
|
||||||
# Plan `0.2.7` — WebSocket Solana standard
|
# Plan `0.2.7` — WebSocket Solana standard
|
||||||
|
|
||||||
> **Statut : actif, `0.2.7-pre.003`.** Les gates `pre.001` et `pre.002` sont clos. `pre.003` matérialise `std.transport` V2 HTTP + WebSocket, la lecture backward V1 HTTP-only et l'adapter `Config -> WsTransportSettings`. Le socket physique reste différé à `pre.004`.
|
> **Statut : actif, `0.2.7-pre.004`.** `pre.003` est validé opérateur. `pre.004` matérialise les dépendances WebSocket, la première session physique actor-owned, le handshake/read/write JSON-RPC, la map bornée de requests en attente et les fixtures serveur local. Les subscriptions typed restent différées.
|
||||||
|
|
||||||
## 1. Objet et base vérifiée
|
## 1. Objet et base vérifiée
|
||||||
|
|
||||||
@@ -366,7 +366,7 @@ Audit au 2026-08-22 :
|
|||||||
| `tokio-websockets` | `0.13.3` | alternative viable, non retenue | strict/minimal et performant, mais exige davantage d'assemblage/features et n'apporte pas de besoin fonctionnel supérieur démontré pour cette foundation |
|
| `tokio-websockets` | `0.13.3` | alternative viable, non retenue | strict/minimal et performant, mais exige davantage d'assemblage/features et n'apporte pas de besoin fonctionnel supérieur démontré pour cette foundation |
|
||||||
| `fastwebsockets` | `0.10.0` | non retenue | plus bas niveau ; peut déléguer davantage de compliance au caller, inutile pour la première foundation KSP |
|
| `fastwebsockets` | `0.10.0` | non retenue | plus bas niveau ; peut déléguer davantage de compliance au caller, inutile pour la première foundation KSP |
|
||||||
|
|
||||||
Landing prévu, **pas dans `pre.001`** :
|
Landing matérialisé par **`pre.004`** :
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
# root [workspace.dependencies]
|
# root [workspace.dependencies]
|
||||||
@@ -379,7 +379,7 @@ futures-util = { workspace = true, features = ["std", "sink"] }
|
|||||||
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
|
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
Le serveur de test local pourra activer `tokio/net` en dev si KSP utilise directement `TcpListener`.
|
Le serveur de test local active `tokio/net` en dev et utilise directement `TcpListener`.
|
||||||
|
|
||||||
`url` n'est pas retenu a priori : l'endpoint KSP peut être validé et passé comme chaîne/request sans ajouter la feature uniquement par habitude bot3. `handshake`/`stream` sont déjà requis transitivement par `connect`/TLS et ne doivent pas être listés sans nécessité directe.
|
`url` n'est pas retenu a priori : l'endpoint KSP peut être validé et passé comme chaîne/request sans ajouter la feature uniquement par habitude bot3. `handshake`/`stream` sont déjà requis transitivement par `connect`/TLS et ne doivent pas être listés sans nécessité directe.
|
||||||
|
|
||||||
@@ -486,6 +486,8 @@ 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.
|
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`.
|
||||||
|
|
||||||
### 9.4 Identités
|
### 9.4 Identités
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -868,7 +870,7 @@ L'inventaire officiel n'impose que 9 familles de subscriptions, mais le lifecycl
|
|||||||
pre.001 audit interne/externe + matrice 18 méthodes + bot3 + dependencies + threat model + plan/sizing
|
pre.001 audit interne/externe + matrice 18 méthodes + bot3 + dependencies + threat model + plan/sizing
|
||||||
pre.002 DONE — settings WS Transport + URL redaction + IDs/states/snapshots + tests de settings
|
pre.002 DONE — settings WS Transport + URL redaction + IDs/states/snapshots + tests de settings
|
||||||
pre.003 DONE — std.transport V2 HTTP+WS + backward V1 + discriminateur WS + schema/fixtures + Config -> WsTransportSettings
|
pre.003 DONE — std.transport V2 HTTP+WS + backward V1 + discriminateur WS + schema/fixtures + Config -> WsTransportSettings
|
||||||
pre.004 deps tokio-tungstenite/futures-util + actor physique + handshake/read/write + pending JSON-RPC + serveur local
|
pre.004 DONE — deps tokio-tungstenite/futures-util + actor physique + handshake/read/write + pending JSON-RPC + serveur local
|
||||||
pre.005 limites frame/message/request + control frames + cancellation/close/shutdown + adversarial socket tests
|
pre.005 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 registry subscriptions + IDs locaux + generic subscribe/unsubscribe engine + channels typed bounded
|
||||||
pre.007 reconnect borné + resubscribe déterministe + continuity gap + races unsubscribe/reconnect
|
pre.007 reconnect borné + resubscribe déterministe + continuity gap + races unsubscribe/reconnect
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- file: docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md -->
|
<!-- file: docs/validation/010-V0_2_7_ONCHAIN_WEBSOCKET.md -->
|
||||||
<!-- version: 5 -->
|
<!-- version: 6 -->
|
||||||
|
|
||||||
# Validation `0.2.7` — WebSocket Solana standard
|
# Validation `0.2.7` — WebSocket Solana standard
|
||||||
|
|
||||||
> **Statut : matrice active, `0.2.7-pre.003`.** Les settings/lifecycle `pre.002` et la composition Config V2 `pre.003` sont matérialisés. Les preuves runtime socket/subscription restent ouvertes.
|
> **Statut : matrice active, `0.2.7-pre.004`.** Settings/lifecycle `pre.002`, Config V2 `pre.003` et première session physique actor-owned `pre.004` sont matérialisés. Registry subscriptions, shutdown complet, reconnect et wrappers typed restent ouverts.
|
||||||
|
|
||||||
## 1. Baseline normative
|
## 1. Baseline normative
|
||||||
|
|
||||||
@@ -190,8 +190,8 @@ Les votes observés sont gossip/pre-consensus ; aucune garantie d'entrée dans l
|
|||||||
## 6. Lifecycle compliance initiale
|
## 6. Lifecycle compliance initiale
|
||||||
|
|
||||||
| Contrat | Décision `pre.001` | Gate cible |
|
| Contrat | Décision `pre.001` | Gate cible |
|
||||||
|-------------------------------|-------------------------------------------------------------------|----------------------|
|
|-------------------------------|-------------------------------------------------------------------|--------------------------------------------|
|
||||||
| plusieurs sessions / même URL | création physique explicite ; aucun singleton/pool automatique | `pre.004`, `pre.012` |
|
| plusieurs sessions / même URL | création physique explicite ; aucun singleton/pool automatique | **Done `pre.004`**, canary final `pre.012` |
|
||||||
| plusieurs subs / session | registry actor par session | `pre.006` |
|
| plusieurs subs / session | registry actor par session | `pre.006` |
|
||||||
| ID public subscription | local KSP stable | `pre.006` |
|
| ID public subscription | local KSP stable | `pre.006` |
|
||||||
| ID serveur | éphémère interne et remappé | `pre.006`/`pre.007` |
|
| ID serveur | éphémère interne et remappé | `pre.006`/`pre.007` |
|
||||||
@@ -207,15 +207,15 @@ Les votes observés sont gossip/pre-consensus ; aucune garantie d'entrée dans l
|
|||||||
## 7. Threat/security compliance initiale
|
## 7. Threat/security compliance initiale
|
||||||
|
|
||||||
| Invariant | Preuve attendue | Statut |
|
| Invariant | Preuve attendue | Statut |
|
||||||
|---------------------------------------------------|---------------------------------------------|--------------------|
|
|---------------------------------------------------|----------------------------------------------|------------------------------------------|
|
||||||
| URL/credentials absents de `Debug` | unit tests URL wrapper | **Done `pre.002`** |
|
| URL/credentials absents de `Debug` | unit tests URL wrapper | **Done `pre.002`** |
|
||||||
| URL/credentials absents des erreurs | validation URL + future connection errors | Partial `pre.002` |
|
| URL/credentials absents des erreurs | validation URL + connection errors safe | **Done through `pre.004`** |
|
||||||
| URL/credentials absents des logs | safe fields `pre.002`, capture actor future | Partial `pre.002` |
|
| URL/credentials absents des logs | actor logs only safe endpoint metadata | Partial `pre.004`, capture finale future |
|
||||||
| snapshots sans URL/raw payload | unit shape + public contract | **Done `pre.002`** |
|
| snapshots sans URL/raw payload | unit shape + public contract | **Done `pre.002`** |
|
||||||
| frame/message finis | settings bornés, enforcement socket futur | Partial `pre.002` |
|
| frame/message finis | settings bornés + `WebSocketConfig` raccordé | Partial `pre.004`, adversarial `pre.005` |
|
||||||
| JSON borné indirectement par message | oversized + malformed fixture | Planned |
|
| JSON borné indirectement par message | oversized + malformed fixture | Planned |
|
||||||
| queues notifications bornées | capacité settings, channel futur | Partial `pre.002` |
|
| queues notifications bornées | capacité settings, channel futur | Partial `pre.002` |
|
||||||
| pending RPC borné + timeout | settings bornés, runtime futur | Partial `pre.002` |
|
| pending RPC borné + timeout | map actor bornée + timeout request | **Done `pre.004`** |
|
||||||
| reconnect loop bornée | repeated disconnect fixture | Planned |
|
| reconnect loop bornée | repeated disconnect fixture | Planned |
|
||||||
| unsubscribe pendant reconnect ne resubscribe pas | race fixture | Planned |
|
| unsubscribe pendant reconnect ne resubscribe pas | race fixture | Planned |
|
||||||
| signature terminale ne resubscribe pas | terminal fixture | Planned |
|
| signature terminale ne resubscribe pas | terminal fixture | Planned |
|
||||||
@@ -249,7 +249,7 @@ https://docs.rs/crate/futures-util/0.3.34
|
|||||||
|
|
||||||
Alternatives auditées mais non retenues : `tokio-websockets 0.13.3`, `fastwebsockets 0.10.0`.
|
Alternatives auditées mais non retenues : `tokio-websockets 0.13.3`, `fastwebsockets 0.10.0`.
|
||||||
|
|
||||||
Aucune de ces dependencies n'est ajoutée par `pre.001`; le graphe Cargo stable ne change pas dans ce gate hors signal de version workspace.
|
`pre.004` matérialise `tokio-tungstenite` et `futures-util` dans `[workspace.dependencies]` sans features consumer au root. Transport active seulement `connect`, `rustls-tls-webpki-roots`, `std` et `sink`; `tokio/net` est ajouté côté dev fixture local.
|
||||||
|
|
||||||
## 9. Config compliance initiale
|
## 9. Config compliance initiale
|
||||||
|
|
||||||
@@ -330,6 +330,34 @@ Logging : `TRACING_TARGET` reste défini dans `constants.rs`; les nouveaux évé
|
|||||||
|
|
||||||
Les defaults de taille/queue sont des **policies KSP locales**, pas des limites Solana. Leur enforcement réel et leurs tests oversized/slow-consumer restent attendus dans les tranches socket/backpressure.
|
Les defaults de taille/queue sont des **policies KSP locales**, pas des limites Solana. Leur enforcement réel et leurs tests oversized/slow-consumer restent attendus dans les tranches socket/backpressure.
|
||||||
|
|
||||||
|
## 9.2 Checkpoint runtime physique `pre.004`
|
||||||
|
|
||||||
|
Surface matérialisée :
|
||||||
|
|
||||||
|
```text
|
||||||
|
WsSession::connect(endpoint) public, une connexion physique par appel
|
||||||
|
actor socket propriétaire exclusif du WebSocket
|
||||||
|
command queue tokio mpsc bounded
|
||||||
|
pending JSON-RPC BTreeMap bounded par max_pending_requests
|
||||||
|
request timeout command_timeout
|
||||||
|
response dispatch par id numérique KSP
|
||||||
|
state snapshot watch + WsSessionSnapshot
|
||||||
|
raw JSON-RPC public non, primitive pub(crate)
|
||||||
|
reconnect/resubscribe non, gates futurs
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixtures déterministes `pre.004` :
|
||||||
|
|
||||||
|
```text
|
||||||
|
handshake local + JSON-RPC round-trip
|
||||||
|
deux sessions physiques distinctes sur la même URL
|
||||||
|
deux requests concurrentes + réponses inversées
|
||||||
|
erreur RPC applicative sans teardown de session
|
||||||
|
erreur de connexion sans URL/credential dans Error Debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Les limites `max_message_size`, `max_frame_size` et `max_write_buffer_size` sont raccordées à `tungstenite::WebSocketConfig`. Les fixtures oversized et le shutdown/control-frame hostile restent explicitement `pre.005`.
|
||||||
|
|
||||||
## 10. Validation du gate `pre.001`
|
## 10. Validation du gate `pre.001`
|
||||||
|
|
||||||
Exécuté dans le sandbox :
|
Exécuté dans le sandbox :
|
||||||
|
|||||||
Reference in New Issue
Block a user